Semaphor
MCP

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.

propose_changesaves nothingproposedexpires in 30 minapplyingone writer at a timeappliedresult recordedfailednothing writtenapplyretry: sameresulttarget changed: proposal_stale, propose again
A proposal saves nothing and expires after 30 minutes. A retry of an applied proposal returns the recorded result instead of writing twice.

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.

Read 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.

Propose: add a bar chart
{
  "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.

Apply
{ "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.

ChangeLogged in (OAuth)Automation tokenEnd-user token (Power User)
Dashboard cards, filters, sheets, tabs, text cards and layoutYesYesYes, on dashboards they can edit in their own tenant
A new dashboard (create)YesYesYes, in their own tenant
relationship.addYesYesNo

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:

ArgumentRequiredDescription
dashboardIdFor changes to a dashboardThe dashboard to change
createFor a new dashboard{ "title", "description" } in place of dashboardId: the changes build a new private dashboard. See Build a New Dashboard.
domainIdFor relationship.addThe semantic domain to change. With dashboard changes it is ignored: each card query and filter names its own domain.
changesYes1 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.
saveAsNo"original" (default) or "copy": save the changes as your own private copy
previewNo"sample" (default, up to 5 rows per changed card), "rows" (up to 50) or "none"
includeNo["outline"] also returns the dashboard's outline as it will be after the changes, as proposedOutline
rationaleNoWhy the change is needed, in up to 500 characters. It's kept with the proposal.
projectIdNoInteractive sessions pass the project for create and relationship.add; dashboard changes use the dashboard's own project

It returns:

Propose response
{
  "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_...\"."
}
FieldWhat it holds
proposalIdThe ID to apply. Proposals expire at expiresAt, 30 minutes after proposing.
risklow 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
summaryOne sentence per change, in order. Show these to the user.
confirmnot_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.
changesThe same sentences with each change's index, op, ref (its handle or ID) and title, and the ruleId a highlight.add creates
warningsThings the proposal does that the user should know about. See Warnings.
preview.cardsUp 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.skippedChanged cards beyond the first 5, not run
preview.failedCards that didn't run: { ref, title, code, message }. The proposal still stands, so fix the query or apply anyway.
proposedOutlineWith include: ["outline"]: the outline as it will be after the changes
nextWhat 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.add or sheet.add a handle such as "$yard" and use it in later changes of the same list wherever a card, filter or sheet ID goes, except in card.remove and filter.remove. A sheet's handle goes in card.add's sheetId, a filter's sheetIds and layout.arrange. A card.add without one gets $card_1, $card_2 and 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.

ChangeFields
card.addtype, title, and query, or text for a text card; optional handle, sheetId, description, matrix, width, position, and the card settings below
card.updatecardId; 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.removecardId
filter.addlabel, domainId, datasetName, field; optional handle, selection, defaultValues, defaultPeriod, sheetIds
filter.updatefilterId; optional label, selection, defaultValues, defaultPeriod, sheetIds (null clears the last three)
filter.removefilterId
filter.bindfilterId, cardId, field: the field of that card's dataset the filter applies through
filter.excludefilterId, cardId: stops the filter applying to that card
layout.arrangeoptional sheetId, keepOrder
layout.tabscardIds: two or more cards on one sheet, the first shown
layout.untabcardId: a card in tabs, given its own spot
sheet.addtitle; optional handle
sheet.updatesheetId, title
highlight.addcardId, column, and when with color, or scale, or bars; optional apply
highlight.removecardId, ruleId
dashboard.updateoptional 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:

FormFields
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, MEDIAN or DISTINCT. "__count" counts rows.
  • timeGrain is minute, hour, day, week, month, quarter or year. timeWindow takes the same windows as semaphor_analyze.
  • filters are { "field", "operator", "values" } with operator one of =, !=, in, not_in, contains, not_contains, between, not_between, >, >=, <, <=, is_null or is_not_null. Add "scope": "aggregate" to filter a measure after it's aggregated.
  • orderBy is { "field", "direction": "asc" | "desc" }. limit is 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.

Make a card a weekly line chart and rename it
{
  "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:

FieldValues
widthfull, 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
A KPI at the top, full width
{
  "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.

Tidy the only grid sheet
{ "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:

A Regions sheet with a chart on it
{
  "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.

Two charts as tabs, the monthly one first
{ "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:

BlockFields
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:

A text card with a heading, a bold figure, a link and a list
{
  "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 from bold, italic and link. 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 editorOnly and kept.
  • A text card's words change with card.update's text; its title and description (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.

A filter that also reaches a card on another dataset
{
  "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 with suggestedFields from the domain's relationships. Add a filter.bind with 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, month or range). A single-day picker takes a one-day default, such as today or yesterday, and a default the picker would narrow is refused with filter_not_editable and a fix. 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 fix says 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:

ReasonThe card
textA text card whose words come from a query
document_sectionA card in a Document section
customA custom visual
needs_sql_accessA SQL card, for a session that can't use SQL
type_not_supportedA 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_expressibleUses 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.

An outline (shortened)
{
  "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": [] }
    ]
  }
}
FieldWhat it holds
updatedAtWhen the dashboard was last saved. An apply checks it: a newer save means proposal_stale.
accesscanEdit: change it in place. canCopy: you can't edit it but can save a private copy with "saveAs": "copy".
datasetsEvery dataset a card or filter uses
sheets[].kinddashboard (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[].layoutGrid 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
truncatedPresent 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:

Apply response
{
  "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": []
}
FieldWhat it holds
appliedtrue when the change was saved
replayedtrue when this is the recorded result of an earlier apply of the same proposal
targetThe dashboard (or domain) changed, with its new updatedAt. url opens it in Semaphor, for organization users. A copy adds copiedFrom.
createdWhat 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" }
summaryThe sentences that were applied
warningsAnything 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.

Propose: a new dashboard with two sheets, tabs, a text card and a filter
{
  "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 target is the new dashboard; organization users get its url, and created lists 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 a dashboard.update in a new dashboard's changes is refused. More than 50 changes are refused with too_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:

Propose: add a relationship
{
  "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."
}
FieldValues
fromThe many side: a dataset name and one or more of its fields
toThe other side, usually a key of that dataset
cardinalitymany_to_one or one_to_one
defaultJoinTypeLEFT or INNER
descriptionOptional

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
SucceededThe recorded result again, with replayed: true
FailedThe recorded error. Propose again if you still want the change.
Is still runningapply_in_progress. Retry the same call shortly.
Started over 5 minutes ago and never recorded a resultapply_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.

CodeMeaningWhat to do
write_scope_requiredThe session doesn't have mcp:write; the error lists requiredScope and grantedScopesLog in with write access, or mint a token with mcp:write
invalid_changeA change doesn't match the tool's fields, or a relationship.add that isn't alone in its proposalFix the fields named in problems
too_many_changesMore than 20 changes for a dashboard, or 50 for a new oneSplit them across proposals
invalid_change_targetThe target doesn't fit the changesDashboard changes take dashboardId; a new dashboard takes create, without dashboardId or saveAs; relationship.add takes domainId
copy_requiredYou can view the dashboard but not edit itPropose again with "saveAs": "copy"
not_permittedYou 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 itAsk an admin for access
project_not_foundThe projectId for create or relationship.add isn't one you can reachUse a project from semaphor_list_projects
dashboard_not_foundThe dashboard doesn't exist, or you can't open itFind it with semaphor_list_dashboards
card_not_found, filter_not_found, sheet_not_foundAn ID isn't on this dashboard; position.after names a card that isn't on a grid sheetRead the outline again and use its IDs
handle_unknownA handle is used before a change defines itDefine the handle in an earlier change, or use an ID
sheet_requiredThe dashboard has several grid sheets and card.add or layout.arrange named nonePick one of the sheets the error lists
sheet_not_a_gridThe sheet is a Document, or the dashboard has no grid sheet, so it takes no card and can't be arrangedUse a grid sheet from the outline, or add one with sheet.add earlier in the list
layout_invalidlayout.tabs names one card, cards on two sheets, a card in a Document section, or a custom visual's inputName two or more cards of one grid sheet
text_invalidA 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_foundA field isn't in the datasetUse one of the fields the error suggests, or read semaphor_get_dataset_schema
relationship_missingA card's datasets aren't related in the semantic model, so the card can't run; details.unreachableDatasets names the datasets it can't reachGroup 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_editableThe outline marks it read only, or the change wouldn't take effectFollow the fix; change it in the editor; removing it still works
type_not_supportedThe query doesn't fit the card typeUse a type the query fits, or send a complete query with the type change
query_form_mismatchA query patch changes form (metric, records, SQL) without a complete query, or mixes keys of two formsSend the complete query of the new form
capability_requiredThe change needs SQL and the session can't use SQLUse a dataset query, or ask an admin for SQL access
document_invalidThe changed dashboard wouldn't save, for example a filter mapped to a field you can no longer useChange the proposal, or read the outline and propose again
proposal_not_foundUnknown or expired proposalId, or another user's proposalPropose again
proposal_staleThe dashboard, a library visual it shows, or the domain changed after the preview; or the proposal was made with dashboard.generate, which create replacesRead it again and propose again
dashboard_on_watch_pageThe dashboard is open on the user's watch page, where the user saves changesDon'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_progressThe first apply is still runningRetry the same call
apply_outcome_unknownThe first apply never recorded a resultCheck with howToCheck before proposing again
apply_failedThe apply failed for another reason, and nothing was savedRead 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.

WarningWhen
type_change_dropsA type change dropped settings the new type can't use; it lists them
label_kept_after_changeA custom column label was kept after its field's aggregate or grain changed, and may no longer fit
filter_not_reaching_cardsA filter doesn't reach some cards on its sheets; it lists them with suggestedFields to bind
detaches_linked_visualChanging the card, or the spot it's in for tabs, makes a library visual's card local to the dashboard
tabs_show_as_kpi_infolayout.tabs: a text card in a KPI's spot shows as the KPI's info, not as a tab
control_binding_droppedControls bound to levels or measures the change removes stop changing the card; details.controlIds lists them
window_saved_as_fixed_datesThe 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_warningrelationship.add: something the domain reported when the relationship was saved
commit_failedApply: the change was saved, but the commit the project requires failed
apply_not_recordedApply: 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:write scope, 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:

ClientBefore semaphor_apply_change runs
Claude CodeAsks 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.aiUses the annotations for default permissions: read-only tools can run without a per-call confirmation, and destructive tools prompt.
CodexFollows your Codex approval settings for MCP tool calls.
Headless tokenNo 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.

On this page