Making Changes
Preview and apply changes to dashboards and semantic models through Semaphor MCP.
Agents can add, change and remove dashboard cards and report filters, change how cards read and show, arrange a dashboard's layout, add and rename sheets, show cards as tabs, write text cards, build a whole new dashboard in one proposal, and add a relationship to a semantic model. Every change goes through two tools: semaphor_propose_change previews it and saves nothing, and semaphor_apply_change saves exactly what was previewed.
To change the dashboard a user is looking at in your product instead, unsaved and with Undo, use a live token: see Live Changes in Embedded Dashboards.
Quick Start
Read the dashboard
The agent calls semaphor_get_dashboard_details with fields: ["outline"]. The outline lists every sheet, card and filter by ID and name, each card's query in the same fields semaphor_analyze uses, the layout of each sheet, which cards and filters can be changed, and whether you can edit the dashboard or need to save a copy. See The outline.
{ "dashboardId": "d_8f2k", "fields": ["outline"] }Propose
The agent calls semaphor_propose_change with the dashboard and a list of changes. It gets back a proposalId, one plain sentence per change, any warnings, and the new numbers for the cards it changed. Nothing is saved.
{
"dashboardId": "d_8f2k",
"changes": [{
"op": "card.add",
"type": "bar",
"title": "Net weight by yard",
"query": {
"domainId": "dom_yard",
"datasetName": "tickets",
"measures": ["net_weight"],
"dimensions": ["yard"],
"dateField": "ticket_date",
"timeWindow": { "unit": "day", "value": 30, "anchor": "now" }
}
}],
"rationale": "Yard managers asked for a 30-day view."
}Review
The agent shows you the sentences and the preview, and waits for you to confirm. Your client may also ask before the apply runs: see Permission and Approval.
Apply
The agent calls semaphor_apply_change with the proposalId, and the changes are saved exactly as previewed.
{ "proposalId": "chg_..." }Who Can Make Changes
semaphor_propose_change appears for every session whose user can author dashboards: an organization user with an Author role or higher, or an end user with the Power User role. Proposing saves nothing, so a read-only session can propose. semaphor_apply_change is listed only for sessions with the mcp:write scope. Which dashboards you can change is decided the same way as in the app: you can edit the dashboards you own or were given edit access to, and save a private copy of any dashboard you can open, if you can create dashboards in its project.
| Change | Logged in (OAuth) | Automation token | End-user token (Power User) |
|---|---|---|---|
| Dashboard cards, filters, sheets, tabs, text cards and layout | Yes | Yes | Yes, on dashboards they can edit in their own tenant |
A new dashboard (create) | Yes | Yes | Yes, in their own tenant |
relationship.add | Yes | Yes | No |
Semantic model changes affect every query on a domain, so who may make one follows your role, the same rule as the semantic domain editor: an organization user with the Author role or higher, in the project's organization, with access to the domain, logged in or with a token. End users never change semantic models, and end users with the Viewer role never get change tools. The SQL query form appears only for sessions that can use SQL.
To apply changes, a session needs write scope: log in with a client that requests mcp:read mcp:write, or mint a token with "mcp": { "scopes": ["mcp:read", "mcp:write"] }. See Authentication and Security and Connect with a Token.
Proposals
semaphor_propose_change takes one target and a list of changes:
| Argument | Required | Description |
|---|---|---|
dashboardId | For changes to a dashboard | The dashboard to change |
create | For a new dashboard | { "title", "description" } in place of dashboardId: the changes build a new private dashboard. See Build a New Dashboard. |
domainId | For relationship.add | The semantic domain to change. With dashboard changes it is ignored: each card query and filter names its own domain. |
changes | Yes | 1 to 20 changes for a dashboard, up to 50 for a new one, applied in order, all or nothing. One proposal holds one kind of target, and a relationship.add is alone in its proposal. |
saveAs | No | "original" (default) or "copy": save the changes as your own private copy |
preview | No | "sample" (default, up to 5 rows per changed card), "rows" (up to 50) or "none" |
include | No | ["outline"] also returns the dashboard's outline as it will be after the changes, as proposedOutline |
rationale | No | Why the change is needed, in up to 500 characters. It's kept with the proposal. |
projectId | No | Interactive sessions pass the project for create and relationship.add; dashboard changes use the dashboard's own project |
It returns:
{
"proposalId": "chg_...",
"expiresAt": "2026-10-16T15:12:00Z",
"risk": "low",
"target": { "kind": "dashboard", "dashboardId": "d_8f2k", "title": "Weights", "saveAs": "original" },
"summary": ["Adds the card \"Net weight by yard\" (bar) to Overview: net weight by yard, last 30 days. The dashboard's \"Yard\" filter applies to it."],
"changes": [
{ "index": 0, "op": "card.add", "ref": "$card_1", "title": "Net weight by yard",
"sentence": "Adds the card \"Net weight by yard\" (bar) to Overview: net weight by yard, last 30 days. The dashboard's \"Yard\" filter applies to it." }
],
"warnings": [],
"preview": {
"cards": [
{ "ref": "$card_1", "title": "Net weight by yard",
"columns": [{ "name": "yard", "label": "Yard" }, { "name": "net_weight", "label": "Net weight" }],
"rows": [["CMR", 412880], ["NWY", 288104], ["EST", 190455]], "rowCount": 3 }
]
},
"next": "Show the summary to the user. After they confirm, call semaphor_apply_change with proposalId \"chg_...\"."
}| Field | What it holds |
|---|---|
proposalId | The ID to apply. Proposals expire at expiresAt, 30 minutes after proposing. |
risk | low when the changes add, update or arrange; medium when they remove or exclude anything, make a library visual's card local to the dashboard, or add a relationship |
target | { "kind": "dashboard", "dashboardId", "title", "saveAs" }, { "kind": "new_dashboard", "title" } for create, or { "kind": "domain", "domainId", "name" } for relationship.add |
summary | One sentence per change, in order. Show these to the user. |
confirm | not_needed when the proposal only changes how cards read and show, the dashboard's title and description, or a sheet's name, so it can be applied at once; needed otherwise, including a new dashboard, a new sheet, tabs and a text card's words. See Confirm. |
changes | The same sentences with each change's index, op, ref (its handle or ID) and title, and the ruleId a highlight.add creates |
warnings | Things the proposal does that the user should know about. See Warnings. |
preview.cards | Up to 5 changed cards run with their new query: columns, rows, rowCount, and truncated: true when there were more rows than the preview shows |
preview.skipped | Changed cards beyond the first 5, not run |
preview.failed | Cards that didn't run: { ref, title, code, message }. The proposal still stands, so fix the query or apply anyway. |
proposedOutline | With include: ["outline"]: the outline as it will be after the changes |
next | What to do now, in a sentence. A session without mcp:write is told to have the user make the change in the dashboard editor, or ask again from a connection that can apply. |
- Only you can apply your proposal, in the same organization and project, with access to the project at the time you apply. Anyone else gets
proposal_not_found. - Handles name new cards, filters and sheets. Give a
card.add,filter.addorsheet.addahandlesuch as"$yard"and use it in later changes of the same list wherever a card, filter or sheet ID goes, except incard.removeandfilter.remove. A sheet's handle goes incard.add'ssheetId, a filter'ssheetIdsandlayout.arrange. Acard.addwithout one gets$card_1,$card_2and so on.
Change a Dashboard
Take cardId, filterId and sheetId values from the outline. On a dashboard with one grid sheet, card.add and layout.arrange use it when you leave out sheetId.
| Change | Fields |
|---|---|
card.add | type, title, and query, or text for a text card; optional handle, sheetId, description, matrix, width, position, and the card settings below |
card.update | cardId; send only what changes, from type, title, description, query, matrix, text (text cards) and the card settings: columns, orientation, stack, donut, totals, dataLabels, legend, axes (see How Cards Read and Show) |
card.remove | cardId |
filter.add | label, domainId, datasetName, field; optional handle, selection, defaultValues, defaultPeriod, sheetIds |
filter.update | filterId; optional label, selection, defaultValues, defaultPeriod, sheetIds (null clears the last three) |
filter.remove | filterId |
filter.bind | filterId, cardId, field: the field of that card's dataset the filter applies through |
filter.exclude | filterId, cardId: stops the filter applying to that card |
layout.arrange | optional sheetId, keepOrder |
layout.tabs | cardIds: two or more cards on one sheet, the first shown |
layout.untab | cardId: a card in tabs, given its own spot |
sheet.add | title; optional handle |
sheet.update | sheetId, title |
highlight.add | cardId, column, and when with color, or scale, or bars; optional apply |
highlight.remove | cardId, ruleId |
dashboard.update | optional title, description (null removes it) |
Cards
type is one of kpi, bar, line, area, pie, table, detail_table, matrix or text. A text card takes text and no query: see Text cards. A Matrix also takes matrix: { "columns": [...] }: the query fields that run across the top; the rest run down the side.
A card's query comes in one of three forms:
| Form | Fields |
|---|---|
Metric query (every type but detail_table) | domainId, datasetName, measures (at least one); optional dimensions, dateField, timeGrain, filters, timeWindow, orderBy, limit |
Records query (detail_table) | domainId, datasetName, columns (at least one); optional filters, dateField, timeWindow, orderBy, limit |
| SQL query (sessions that can use SQL) | connectionId, sql |
- Fields are names from
semaphor_get_dataset_schema, or{ "name", "datasetName" }for a field of a related dataset. - Measures may add an
aggregate:SUM,COUNT,AVG,MIN,MAX,MEDIANorDISTINCT."__count"counts rows. timeGrainisminute,hour,day,week,month,quarteroryear.timeWindowtakes the same windows assemaphor_analyze.filtersare{ "field", "operator", "values" }withoperatorone of=,!=,in,not_in,contains,not_contains,between,not_between,>,>=,<,<=,is_nulloris_not_null. Add"scope": "aggregate"to filter a measure after it's aggregated.orderByis{ "field", "direction": "asc" | "desc" }.limitis up to 1,000 rows.
Card filters saved in the editor read in the same vocabulary: one that matches text starting with "V" reads as contains with the value V%, which runs the same match.
card.update's query is a patch: keys you send replace those keys, keys you leave out stay, null clears an optional key, and arrays replace whole. A change from one query form to another needs the complete query of the new form; otherwise it's refused with query_form_mismatch.
{
"dashboardId": "d_8f2k",
"changes": [
{ "op": "card.update", "cardId": "c_trend", "type": "line", "title": "Weekly weight", "query": { "timeGrain": "week" } }
]
}A type change works the way the type picker does in the editor: settings the new type can use stay, the others go, and the warning type_change_drops says what went. When you change a field's aggregate or time grain, the column keeps its label and format, and a custom label that may no longer fit is reported with label_kept_after_change.
A change touches only what it names. A new row limit leaves the card's measures, groupings and filters exactly as they were, adding a measure keeps every existing column as it is, and a new time grain changes the date where it sits, so a chart grouped by segment and then month stays that way. This holds for cards built in the editor too. Renaming a column alone, such as { "dateField": { "name": "order_date", "label": "Order day" } }, renames it in place and changes nothing else, and the editor keeps the new name. A row limit you set comes with a sort, so a limited card shows its top rows, not arbitrary ones; otherwise a card without a sort keeps none, as in the editor. Clearing the limit ("limit": null) leaves the 100 rows a new card shows.
The sentence for an added card says which dashboard filters reach it. A card that shows a library visual becomes local to the dashboard when you change it, as Apply Local does in the editor; the proposal says so with detaches_linked_visual.
Where a card goes
Without width or position, a new card is placed by its kind: a KPI joins the KPI row on top, a chart pairs with a lone chart in the last row, and a table or text card goes to the end at full width. Give a place only when the user names one:
| Field | Values |
|---|---|
width | full, half, third or quarter. With no position, the card goes to the end at this width. |
position | "top", "end", or { "after": "<cardId or handle>" }: right after that card, on that card's sheet |
{
"dashboardId": "d_8f2k",
"changes": [{
"op": "card.add", "type": "kpi", "title": "Net weight, last 30 days",
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"],
"dateField": "ticket_date", "timeWindow": { "unit": "day", "value": 30, "anchor": "now" } },
"position": "top", "width": "full"
}]
}Layout is always stated in these words, never as coordinates. Every other screen size follows the desktop layout.
Arrange a sheet
layout.arrange lays a grid sheet out on every screen size: an introductory text card on top, then KPIs in a row, charts in pairs, tables full width and any other text. "keepOrder": true keeps the cards in their current order and only fixes sizes and gaps. Arranging an empty sheet changes nothing and says so.
{ "dashboardId": "d_8f2k", "changes": [{ "op": "layout.arrange" }] }Sheets
sheet.add adds a grid sheet at the end, and sheet.update renames one. Give a new sheet a handle to put cards and filters on it in the same proposal:
{
"dashboardId": "d_8f2k",
"changes": [
{ "op": "sheet.add", "handle": "$regions", "title": "Regions" },
{ "op": "card.add", "sheetId": "$regions", "type": "bar", "title": "Net weight by region",
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"], "dimensions": ["region"] } }
]
}The sentences read Adds the sheet "Regions". and Renames the sheet "Report" to "Monthly report". Renaming a sheet applies at once; a new sheet asks the user first.
Cards as tabs
layout.tabs puts two or more cards of one sheet in one spot as tabs, the first named shown. layout.untab gives a card its own spot again, beside the tabs it left, at the tabs' size. Cards keep their IDs, settings, filter bindings and highlights; nothing is removed and added again.
{ "dashboardId": "d_8f2k", "changes": [{ "op": "layout.tabs", "cardIds": ["c_monthly", "c_stage"] }] }The outline's layout lists a spot's cards with the shown one first, so reading a spot and sending its cardIds back changes nothing. A text card put in a spot with a KPI shows as the KPI's info, not as a tab, and the proposal says so with tabs_show_as_kpi_info.
Text cards
A text card's text is a list of blocks, shaped like the text editor's own content:
| Block | Fields |
|---|---|
| Heading | { "type": "heading", "level": 1, "content": [...] }, level 1 or 2 |
| Paragraph | { "type": "paragraph", "content": [...] }; "content": [] is an empty line for spacing |
| List | { "type": "list", "style": "bulleted", "items": [[...], [...]] }, style bulleted or numbered, one level |
content and each list item are runs. A run is a string, or an object with text or variable and any of bold, italic and link:
{
"dashboardId": "d_8f2k",
"changes": [{
"op": "card.add", "type": "text", "title": "About",
"text": [
{ "type": "heading", "level": 1, "content": ["Q3 review"] },
{ "type": "paragraph", "content": ["Net weight rose ", { "text": "12%", "bold": true },
". See ", { "text": "the method", "link": "https://example.com/method" }, "."] },
{ "type": "list", "style": "bulleted", "items": [["Inbound up"], ["Outbound flat"]] }
]
}]
}- Text is taken as written.
**,#,_or[x](y)in a run are those characters, so formatting comes only frombold,italicandlink. A new line is a new block. - Links follow the text editor's rule: web, mail and phone links, and links within the app.
- Variables. A text card that shows a filter's value reads it as
{ "variable": "facility.label" }. A change keeps the card's own variables; add a new one in the editor. - What a change keeps. The outline reads a text card in the same blocks. Send them back with your change, and every block you leave as it reads keeps everything stored with it, such as a variable's date format. What the blocks can't state, such as underline from the older text editor, is listed in the card's
editorOnlyand kept. - A text card's words change with
card.update'stext; itstitleanddescription(shown under the title) change as any card's.
Filters
A filter applies to the cards on its sheets that it can reach; the outline's reaches lists them. selection is single or multiple (default). sheetIds defaults to every grid sheet. defaultPeriod is a time window for a date filter.
{
"dashboardId": "d_8f2k",
"changes": [
{ "op": "filter.add", "handle": "$yard", "label": "Yard", "domainId": "dom_yard", "datasetName": "tickets",
"field": "yard", "selection": "single", "defaultValues": ["CMR"] },
{ "op": "filter.bind", "filterId": "$yard", "cardId": "c_scale", "field": "yard_code" }
]
}- A new or changed filter that doesn't reach some cards on its sheets comes back with the warning
filter_not_reaching_cards, listing those cards withsuggestedFieldsfrom the domain's relationships. Add afilter.bindwith one of them, or leave the cards unfiltered. - Binding a filter to a card the filter leaves out by name brings the card back in.
- A filter default loads as you ask for it. The outline says which date picker a date filter uses (
datePicker:day,monthorrange). A single-day picker takes a one-day default, such as today or yesterday, and a default the picker would narrow is refused withfilter_not_editableand afix. A relative default runs to now, so one anchored to the latest available data is refused. - Anything that wouldn't take effect is refused rather than saved: a filter not shown on the card's sheet, a binding to a field of another type, or a card change that would leave a filter with nothing to filter. The
fixsays what to do. A proposal never reports a change that didn't happen.
What can be changed
The outline marks each card and filter editable. A card that isn't carries notEditable.reason:
| Reason | The card |
|---|---|
text | A text card whose words come from a query |
document_section | A card in a Document section |
custom | A custom visual |
needs_sql_access | A SQL card, for a session that can't use SQL |
type_not_supported | A chart type beyond the eight above and their variants (horizontal and stacked bars, stacked lines and areas, doughnuts, which read as their type with a property) |
not_expressible | Uses something the public query can't state (a database table instead of a dataset, a Python card, a join set up in the editor); detail names it |
These cards can be read and removed, and are changed in the editor. A filter that isn't editable has kind and a reason: global_date (the report's date filter), not_semantic (a filter on a database table) or scope_not_expressible (a filter scoped to chosen cards). Those can be removed or excluded through the MCP.
The Outline
semaphor_get_dashboard_details with fields: ["outline"] returns the dashboard in the change vocabulary. Narrow it with sheetIds or cardIds.
{
"outline": {
"dashboardId": "d_8f2k",
"title": "Weights",
"updatedAt": "2026-10-16T14:02:11.420Z",
"access": { "canEdit": true, "canCopy": true },
"datasets": [{ "domainId": "dom_yard", "datasetName": "tickets" }],
"sheets": [
{
"sheetId": "s_overview", "title": "Overview", "kind": "dashboard",
"cards": [
{ "cardId": "c_total", "title": "Net weight", "type": "kpi", "editable": true,
"source": { "kind": "semantic", "domainId": "dom_yard", "datasetName": "tickets" },
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"] } },
{ "cardId": "c_trend", "title": "Weight by day", "type": "line", "editable": true,
"source": { "kind": "semantic", "domainId": "dom_yard", "datasetName": "tickets" },
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"],
"dateField": "ticket_date", "timeGrain": "day" } },
{ "cardId": "c_notes", "title": "Notes", "type": "text", "editable": true,
"text": [{ "type": "paragraph", "content": ["Weights for ", { "variable": "yard.label" }, "."] }] }
],
"layout": [
{ "tiles": [{ "cardIds": ["c_total"], "width": "full" }] },
{ "tiles": [{ "cardIds": ["c_trend"], "width": "half" }, { "cardIds": ["c_notes"], "width": "half" }] }
]
}
],
"filters": [
{ "filterId": "f_yard", "label": "Yard", "editable": true, "domainId": "dom_yard", "datasetName": "tickets",
"field": "yard", "selection": "multiple", "sheetIds": "all",
"reaches": ["c_total", "c_trend"], "excluded": [], "notReached": [] }
]
}
}| Field | What it holds |
|---|---|
updatedAt | When the dashboard was last saved. An apply checks it: a newer save means proposal_stale. |
access | canEdit: change it in place. canCopy: you can't edit it but can save a private copy with "saveAs": "copy". |
datasets | Every dataset a card or filter uses |
sheets[].kind | dashboard (a grid sheet, which takes cards) or document (its section cards are listed, never editable) |
sheets[].cards[] | cardId, title, type, source, editable, the query (and matrix) for editable cards, text (and editorOnly) for a text card, linkedVisualId for a card showing a library visual, notEditable otherwise |
sheets[].layout | Grid sheets only: the desktop layout as rows from the top. Each tile is one frame with its width (full, half, third, quarter) and its cards, the shown card first (a frame can hold cards as tabs). It always covers the whole sheet, even when cardIds narrows the cards. |
filters[] | Each filter with its field, selection, defaults, datePicker, sheetIds ("all", or a list), exceptSheetIds, includesDocumentSheets, and its reach: reaches (card IDs it applies to), excluded, notReached (in scope, not matched) and blocked |
truncated | Present when the dashboard has more than 50 cards: queries are left out. Narrow with sheetIds or cardIds to get them. |
Save, Copy and Apply
Saving a copy. When the outline says "access": { "canEdit": false, "canCopy": true }, propose with "saveAs": "copy". The copy is private to you, keeps the original's card and filter IDs, and is named "title (copy)". The apply response names the copy in target and the original in target.copiedFrom.
semaphor_apply_change returns:
{
"applied": true,
"replayed": false,
"target": { "kind": "dashboard", "dashboardId": "d_8f2k", "title": "Weights", "updatedAt": "2026-10-16T14:20:03.114Z",
"url": "https://semaphor.cloud/project/p_.../dashboard/d_8f2k" },
"created": [{ "handle": "$yard", "kind": "filter", "id": "f_91c2", "title": "Yard" }],
"summary": ["Adds the filter \"Yard\" on every sheet, single select, default CMR."],
"warnings": []
}| Field | What it holds |
|---|---|
applied | true when the change was saved |
replayed | true when this is the recorded result of an earlier apply of the same proposal |
target | The dashboard (or domain) changed, with its new updatedAt. url opens it in Semaphor, for organization users. A copy adds copiedFrom. |
created | What the apply created, with the IDs to use from now on: cards, filters and sheets (with their handle), and for a copy or a new dashboard, { "kind": "dashboard", "id", "title" } |
summary | The sentences that were applied |
warnings | Anything to tell the user, such as commit_failed |
What apply checks. Apply saves only if the dashboard is still the one previewed: if anyone saved it, or a library visual it shows changed, it returns proposal_stale and saves nothing. Read the outline again and propose again. Apply also checks your access again as it is now, so copy_required, not_permitted or capability_required can come from apply too.
Commits. In projects that require a commit message, an organization user's apply commits with the proposal's sentences followed by "(through the MCP)". End users' changes never commit. If the commit fails after the save, the change stays saved and the response carries the warning commit_failed.
Watch Changes Land in Your Browser
When you're logged in to Semaphor MCP with your own account, you can watch the agent's changes land on a dashboard in your browser, and save them yourself.
Open the watch link. Ask the agent for the dashboard's watch link, and open it in a browser where you're signed in to Semaphor. The dashboard opens with Watching beside its title. You need to be able to edit the dashboard.
Ask for changes. Each change the agent makes to that dashboard appears on the page as it's made, unsaved, with Undo, and the page scrolls to the card that changed. You can edit by hand between the agent's changes.
Keep what you want. Save keeps everything on the page. Undo removes a change, and Close leaves without saving.
While you watch, you save. The agent doesn't save the watched dashboard itself, and tells you the changes are waiting on your page. A private copy ("saveAs": "copy") is the exception: it's a new dashboard, so the agent proposes and applies it as usual.
Stop watching. Open Watching beside the title and choose Stop watching to hand saving back to the agent: from its next change on, it proposes and applies as usual. Anything unsaved stays on the page for you to save or drop. Watch again turns watching back on. Watching also ends 8 hours after you opened the page.
If you close the page. Watching stays on until you press Stop watching, so the agent can't change the dashboard and asks you to reopen the watch link. Reopen it, or press Stop watching there to let the agent save.
One page at a time. If you open the link again in another tab, the newest tab receives the changes, and the older tab says "Not watching here" once you stop watching there.
Without the watch page, the agent proposes and applies its changes as described above.
For agents: semaphor_get_dashboard_details returns watchUrl, the link, and watching, true while the user's watch page is on, in logged-in sessions. While it's true, semaphor_propose_change puts the change on the page and returns delivery with what became of it; follow its next and don't apply. When the page is closed, reads and proposals return screen_unavailable.
Build a New Dashboard
Send create in place of dashboardId, with the changes that fill the new dashboard: the same changes you use to edit one, up to 50. Start with sheet.add for each sheet; with none, the dashboard has one sheet.
{
"projectId": "p_...",
"create": { "title": "Yard review", "description": "Weekly" },
"changes": [
{ "op": "sheet.add", "handle": "$overview", "title": "Overview" },
{ "op": "sheet.add", "handle": "$detail", "title": "Detail" },
{ "op": "card.add", "sheetId": "$overview", "type": "text", "title": "About",
"text": [{ "type": "heading", "level": 1, "content": ["Yard review"] }, { "type": "paragraph", "content": ["Figures update daily."] }] },
{ "op": "card.add", "handle": "$total", "sheetId": "$overview", "type": "kpi", "title": "Net weight",
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"] } },
{ "op": "card.add", "handle": "$trend", "sheetId": "$overview", "type": "line", "title": "Net weight by week",
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"], "dateField": "ticket_date", "timeGrain": "week" } },
{ "op": "layout.tabs", "cardIds": ["$trend", "$total"] },
{ "op": "card.add", "sheetId": "$detail", "type": "table", "title": "By yard",
"query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"], "dimensions": ["yard"] } },
{ "op": "filter.add", "label": "Yard", "domainId": "dom_yard", "datasetName": "tickets", "field": "yard", "sheetIds": ["$overview", "$detail"] }
]
}- The preview is real. The proposal's first sentence says the whole (
Creates a private dashboard "Yard review" with 2 sheets, 4 cards, and 1 filter.), then one sentence per change, and the changed cards run with real numbers.include: ["outline"]returns the new dashboard's outline as it will be after apply. - Apply saves it private to you, exactly as previewed, with the IDs the proposal showed. The apply response's
targetis the new dashboard; organization users get itsurl, andcreatedlists the dashboard, its sheets, cards and filters. - Who can. Anyone who can create dashboards in the project: organization users, and end users with the Power User role, whose dashboard is saved in their own tenant.
- Retries are safe. Applying the same proposal again returns the recorded result; it never saves a second dashboard.
- The title and description go in
create, so adashboard.updatein a new dashboard's changes is refused. More than 50 changes are refused withtoo_many_changes: create with the first 50, then change the new dashboard in a second proposal.
Add a Relationship
relationship.add adds a relationship between two datasets of a domain, so queries can join them:
{
"domainId": "dom_abc",
"changes": [{
"op": "relationship.add",
"from": { "dataset": "orders", "fields": ["customer_id"] },
"to": { "dataset": "customers", "fields": ["id"] },
"cardinality": "many_to_one",
"defaultJoinType": "LEFT"
}],
"rationale": "Break revenue down by customer segment."
}| Field | Values |
|---|---|
from | The many side: a dataset name and one or more of its fields |
to | The other side, usually a key of that dataset |
cardinality | many_to_one or one_to_one |
defaultJoinType | LEFT or INNER |
description | Optional |
The relationship is validated when you propose. If a field doesn't exist, or the domain isn't one you can open, propose returns invalid_change and stores nothing; if your role or organization can't change the domain, it returns not_permitted. If someone edits the domain between your preview and your apply, apply returns proposal_stale. A relationship apply returns target: { "kind": "domain", "domainId" } and an empty created.
Retries
semaphor_apply_change is safe to retry with the same proposalId. A change is never applied twice.
| You call apply again and the first apply... | You get |
|---|---|
| Succeeded | The recorded result again, with replayed: true |
| Failed | The recorded error. Propose again if you still want the change. |
| Is still running | apply_in_progress. Retry the same call shortly. |
| Started over 5 minutes ago and never recorded a result | apply_outcome_unknown, with a howToCheck message that says how to check whether the change landed |
If your client loses the response to an apply, retry the same call. You get the same result back, not a second copy or dashboard. Records are kept for 24 hours, so a retry still answers after the proposal itself has expired. After apply_outcome_unknown, follow howToCheck before proposing again.
Errors
Change tools return errors as isError: true results with a code, a message and, for most codes, a fix that says what to do next. Problems with individual changes are listed in problems, each with a path such as changes[2].query.measures[0], and the top-level code is the first problem's. A list with any problem is refused whole, and nothing is stored.
| Code | Meaning | What to do |
|---|---|---|
write_scope_required | The session doesn't have mcp:write; the error lists requiredScope and grantedScopes | Log in with write access, or mint a token with mcp:write |
invalid_change | A change doesn't match the tool's fields, or a relationship.add that isn't alone in its proposal | Fix the fields named in problems |
too_many_changes | More than 20 changes for a dashboard, or 50 for a new one | Split them across proposals |
invalid_change_target | The target doesn't fit the changes | Dashboard changes take dashboardId; a new dashboard takes create, without dashboardId or saveAs; relationship.add takes domainId |
copy_required | You can view the dashboard but not edit it | Propose again with "saveAs": "copy" |
not_permitted | You can't make this change at all, for example create a dashboard in this project, change this semantic domain, or commit where the project requires it | Ask an admin for access |
project_not_found | The projectId for create or relationship.add isn't one you can reach | Use a project from semaphor_list_projects |
dashboard_not_found | The dashboard doesn't exist, or you can't open it | Find it with semaphor_list_dashboards |
card_not_found, filter_not_found, sheet_not_found | An ID isn't on this dashboard; position.after names a card that isn't on a grid sheet | Read the outline again and use its IDs |
handle_unknown | A handle is used before a change defines it | Define the handle in an earlier change, or use an ID |
sheet_required | The dashboard has several grid sheets and card.add or layout.arrange named none | Pick one of the sheets the error lists |
sheet_not_a_grid | The sheet is a Document, or the dashboard has no grid sheet, so it takes no card and can't be arranged | Use a grid sheet from the outline, or add one with sheet.add earlier in the list |
layout_invalid | layout.tabs names one card, cards on two sheets, a card in a Document section, or a custom visual's input | Name two or more cards of one grid sheet |
text_invalid | A text card's run can't be stored: a line break in text, a link the text editor drops, both or neither of text and variable, or a variable the card doesn't have. The path names the run. | Follow the fix; add a new variable in the editor |
field_not_found | A field isn't in the dataset | Use one of the fields the error suggests, or read semaphor_get_dataset_schema |
relationship_missing | A card's datasets aren't related in the semantic model, so the card can't run; details.unreachableDatasets names the datasets it can't reach | Group by a field of a related dataset, add the missing relationship with relationship.add, or build the card from SQL |
card_not_editable, filter_not_editable | The outline marks it read only, or the change wouldn't take effect | Follow the fix; change it in the editor; removing it still works |
type_not_supported | The query doesn't fit the card type | Use a type the query fits, or send a complete query with the type change |
query_form_mismatch | A query patch changes form (metric, records, SQL) without a complete query, or mixes keys of two forms | Send the complete query of the new form |
capability_required | The change needs SQL and the session can't use SQL | Use a dataset query, or ask an admin for SQL access |
document_invalid | The changed dashboard wouldn't save, for example a filter mapped to a field you can no longer use | Change the proposal, or read the outline and propose again |
proposal_not_found | Unknown or expired proposalId, or another user's proposal | Propose again |
proposal_stale | The dashboard, a library visual it shows, or the domain changed after the preview; or the proposal was made with dashboard.generate, which create replaces | Read it again and propose again |
dashboard_on_watch_page | The dashboard is open on the user's watch page, where the user saves changes | Don't apply. A change proposed while the page was open is already on it; otherwise propose it again. The user can press Stop watching to have you save |
apply_in_progress | The first apply is still running | Retry the same call |
apply_outcome_unknown | The first apply never recorded a result | Check with howToCheck before proposing again |
apply_failed | The apply failed for another reason, and nothing was saved | Read the message; propose again |
Warnings
Warnings come back in warnings on a proposal or an apply. They don't stop anything; show them to the user.
| Warning | When |
|---|---|
type_change_drops | A type change dropped settings the new type can't use; it lists them |
label_kept_after_change | A custom column label was kept after its field's aggregate or grain changed, and may no longer fit |
filter_not_reaching_cards | A filter doesn't reach some cards on its sheets; it lists them with suggestedFields to bind |
detaches_linked_visual | Changing the card, or the spot it's in for tabs, makes a library visual's card local to the dashboard |
tabs_show_as_kpi_info | layout.tabs: a text card in a KPI's spot shows as the KPI's info, not as a tab |
control_binding_dropped | Controls bound to levels or measures the change removes stop changing the card; details.controlIds lists them |
window_saved_as_fixed_dates | The card stores the date window as fixed dates, so it won't move forward on later loads. Use a rolling window anchored to now, or a calendar window, to keep it moving. |
relationship_warning | relationship.add: something the domain reported when the relationship was saved |
commit_failed | Apply: the change was saved, but the commit the project requires failed |
apply_not_recorded | Apply: the change was saved, but its result couldn't be recorded. A retry reports apply_outcome_unknown; don't propose the change again. |
Permission and Approval
These are two different things.
- Permission is enforced by Semaphor on every call: the
mcp:writescope, your role, the per-change rule above, and your permissions on the dashboard or project. - Approval of a particular change happens in your client, before it calls
semaphor_apply_change.
semaphor_apply_change is marked as destructive and semaphor_propose_change as read only. These MCP annotations are advisory hints: they don't force a confirmation dialog, and Semaphor can't prove that a person saw the preview. What happens before apply runs depends on the client:
| Client | Before semaphor_apply_change runs |
|---|---|
| Claude Code | Asks before an MCP tool runs, unless the tool is already allowed. You can choose "Yes, and don't ask again", or add the tool to permissions.allow (for a server you configured yourself, the rule is mcp__<server>__<tool>). Claude Code doesn't use the annotations to decide. Running with permissions bypassed skips these prompts. In non-interactive claude -p runs, a call that would ask is denied unless --allowedTools or an allow rule covers it. |
| claude.ai | Uses the annotations for default permissions: read-only tools can run without a per-call confirmation, and destructive tools prompt. |
| Codex | Follows your Codex approval settings for MCP tool calls. |
| Headless token | No per-change approval. Minting a token with mcp:write is delegated authority: whoever minted it approved writes in advance. |
For an unattended Claude Code run that should read but never write, allow only the read tools, for example --allowedTools "mcp__semaphor-token__semaphor_analyze,mcp__semaphor-token__semaphor_get_analysis_context", or mint the token without mcp:write. The second is the stronger control, because the server then refuses writes whatever the client does.
See Authentication and Security for more.
Discover and Query
How agents find the right data in your semantic model, then run governed analytics, Matrix windows or SQL.
How Cards Read and Show
Change a card's column headers, number and date formats, hidden columns, widths, total row, data labels, legend, axes, chart variant and highlights, and a dashboard's title, through Semaphor MCP.