Signal Quick Start
In this tutorial you build Team notes, a page where a team keeps short notes. It has a primary button, a dialog with a form, a table, an empty state, and a delete step with a confirm dialog.
You need to know how to pack and upload a plugin. If you do not, do Your First Plugin first.
Step 1: Make the folders
mkdir -p team-notes/dist team-notes/ui cd team-notes
At the end, you have these files:
team-notes/ manifest.json dist/main.js ui/index.html ui/app.js
Step 2: Write the manifest
manifest.json:
{ "id": "local.team-notes", "name": "Team notes", "version": "1.0.0", "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" }, "ui": { "pages": [ { "slot": "nav", "title": "Team notes", "path": "ui/index.html" } ] } }
ui.pages adds a page to the sidebar. The nav slot means a full page. This plugin needs no permissions, because it uses only its own storage.
Step 3: Write the server side
dist/main.js:
const MAX_TEXT = 500; async function loadNotes(ctx) { const rows = await ctx.store.list("note:"); return rows.map((row) => row.value).sort((a, b) => b.created_at.localeCompare(a.created_at)); } export default definePlugin({ async pageData({ input }, ctx) { if (input.action === "add") { const text = String(input.text ?? "").trim().slice(0, MAX_TEXT); if (!text) return { error: "Write a note first." }; const id = crypto.randomUUID(); await ctx.store.set(`note:${id}`, { id, text, created_at: new Date().toISOString() }); } if (input.action === "delete") { await ctx.store.delete(`note:${input.id}`); } return { notes: await loadNotes(ctx) }; }, });
pageData is the one function that the page calls. The page sends an action, the function does it, and it always returns the fresh list. It checks the input, because the input comes from a browser.
Your code runs on the server, in the sandbox. Your page runs in the browser. They talk only through pageData.
Step 4: Write the page
ui/index.html:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Team notes</title> <link rel="stylesheet" href="/plugin-kit/v1/kit.css"> <script type="module" src="/plugin-kit/v1/kit.js"></script> <script type="module" src="app.js"></script> </head> <body> <sg-page title="Team notes" description="Short notes for everyone who works in this channel." width="narrow"> <sg-button slot="actions" id="new" variant="primary" icon="plus">New note</sg-button> <sg-card flush> <sg-table id="notes" empty-title="No notes yet" empty-text="Click New note to add the first one."></sg-table> </sg-card> </sg-page> <sg-dialog id="dialog" title="New note"> <sg-form id="form"> <sg-textarea name="text" label="Note" required maxlength="500"></sg-textarea> <sg-stack direction="row" justify="end" gap="small"> <sg-button id="cancel">Cancel</sg-button> <sg-button type="submit" variant="primary">Save note</sg-button> </sg-stack> </sg-form> </sg-dialog> </body> </html>
Look at how little is here:
sg-pagedraws the title and the description.slot="actions"puts the button at the top right.sg-card flushremoves the card’s padding, so the table reaches the edges.sg-tabledraws itself, andempty-titlesets its empty state.sg-dialogstays hidden until you open it.
Step 5: Write the script
ui/app.js:
const table = document.getElementById("notes"); const dialog = document.getElementById("dialog"); const form = document.getElementById("form"); function deleteButton(note) { const button = document.createElement("sg-button"); button.setAttribute("size", "small"); button.setAttribute("variant", "ghost"); button.textContent = "Delete"; button.addEventListener("click", () => removeNote(note)); return button; } table.columns = [ { key: "text", label: "Note" }, { key: "created_at", label: "Added", sortable: true, render: (note) => note.created_at.slice(0, 10) }, { key: "actions", label: "", align: "right", render: deleteButton }, ]; async function load(input = {}) { try { const data = await sg.pageData(input); if (data.notes) table.rows = data.notes; return data; } catch (error) { table.rows = []; sg.toast(`The page could not load: ${error.message}`, { tone: "danger" }); return null; } } async function removeNote(note) { const yes = await sg.confirm({ title: "Delete this note?", message: note.text, confirmLabel: "Delete note", danger: true, }); if (!yes) return; await load({ action: "delete", id: note.id }); sg.toast("Note deleted"); } document.getElementById("new").addEventListener("click", () => dialog.open()); document.getElementById("cancel").addEventListener("click", () => dialog.close()); form.addEventListener("sg-submit", async (event) => { form.busy = true; const data = await load({ action: "add", text: event.detail.values.text }); form.busy = false; if (!data) return; if (data.error) { form.errors = { text: data.error }; return; } form.reset(); dialog.close(); sg.toast("Note saved"); }); await load();
Read it from the top:
table.columnssays what to show.renderlets a column show any element, here a Delete button.loadcallssg.pageData, and it puts the notes in the table. Settingtable.rowsredraws the table.- If the call fails,
loadsetsrowsto an empty list. Without this, the table shows its loading state forever. removeNoteasks first, withsg.confirm.window.confirmdoes not work in the sandbox.- The form sends
sg-submitonly when the browser’s checks pass. So a required field is checked for you. form.busy = trueturns off the submit button while the call runs. So a double click cannot save twice.form.errorsshows an error from the server under its field.
Step 6: Pack and install
zip -r -X ../team-notes-1.0.0.broadcast-plugin manifest.json dist ui
Upload the file, deploy it and turn it on in a channel, as in Your First Plugin.
Step 7: See it work
- Open Plugins in the sidebar, then Team notes.
You should see the title, the blue New note button and the text No notes yet.
- Click New note, then Save note with the box empty.
You should see an error under the box. The dialog stays open.
- Type a note and click Save note.
The dialog closes, a small message says Note saved, and the note is in the table.
- Click Delete on the row, then Delete note.
The table shows No notes yet again.
If it goes wrong
| What you see | Reason |
|---|---|
| Plain text with no style, and no title | kit.css or kit.js did not load. Check the two lines in <head> |
sg is not defined in the console |
Your script runs before kit.js. Put kit.js first |
| The table shows grey bars forever | rows was never set. Set it also when a call fails |
| Error: plugin_error in a message | Your pageData threw. Read the plugin’s logs |
| The change you made does not show | Files are kept for 5 minutes. Release a new version |
Next steps
- Signal Recipes: forms, search, pagination, a widget and a panel.
- Signal Forms and Signal Data: every option of what you used here.