Making Changes
Preview and apply dashboard and semantic model changes through Semaphor MCP.
Agents can create a dashboard or 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 applies exactly what was previewed.
Quick Start
Propose
The agent calls semaphor_propose_change and gets back a proposalId and a readable preview of what apply will do. Nothing is saved.
{
"target": { "kind": "dashboard" },
"operations": [{
"op": "create",
"request": {
"prompt": "Weekly revenue by region, with top customers",
"domainId": "dom_abc",
"datasetNames": ["orders"]
}
}],
"rationale": "Sales leads asked for a weekly regional view."
}Review
The agent shows you the preview. 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 change is applied exactly as previewed.
{ "proposalId": "chg_..." }Who Can Make Changes
The change tools appear only for organization users with an Author role or higher whose session has the mcp:write scope. What each session can change also depends on how it connected:
| Kind | Operation | Logged in (OAuth) | Automation token | End-user token |
|---|---|---|---|---|
dashboard | create | Yes | Yes | No |
semantic_model | add_relationship | Yes | No | No |
Semantic model changes affect every query on a domain, so they need an interactive login. A token session that proposes one gets change_not_allowed_for_credential. End users never get change tools.
To get 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:
| Argument | Required | Description |
|---|---|---|
target | Yes | What to change: { "kind": "dashboard" } or { "kind": "semantic_model", "domainId": "..." } |
operations | Yes | Exactly one operation for that kind |
rationale | No | Why the change is needed, in a sentence. It's added to the preview summary. |
projectId | No | Interactive sessions pass the project; token sessions use the token's project |
It returns:
{
"proposalId": "chg_...",
"expiresAt": "2026-10-04T18:30:00.000Z",
"preview": {
"summary": "Create a private dashboard \"Weekly Revenue by Region\" with 3 cards on 1 sheet, created private like a dashboard you create in the app. Rationale: Sales leads asked for a weekly regional view.",
"changes": [
{ "action": "create", "entity": "dashboard", "name": "Weekly Revenue by Region", "detail": { "datasets": ["orders"], "sheets": ["Overview"] } },
{ "action": "create", "entity": "card", "name": "Revenue by Week", "detail": { "type": "line", "sheet": "Overview" } }
]
},
"validation": { "errors": [], "warnings": [], "repairHints": [] },
"risk": "low"
}- Proposals expire after 30 minutes. After that, propose again.
- Only you can apply your proposal, in the same project. Anyone else gets
proposal_not_found. riskislowfor a new private dashboard andmediumfor a relationship, because every query on the domain can use it.
Create a Dashboard
Kind dashboard, operation create. Describe the dashboard in a prompt over one semantic domain:
| Field | Required | Description |
|---|---|---|
request.prompt | Yes | What the dashboard should show |
request.domainId | Yes | The semantic domain to build on, from semaphor_list_semantic_domains |
request.datasetName | No | One dataset to use |
request.datasetNames | No | Several datasets to use |
Semaphor plans the dashboard once, when you propose, and stores that plan. Apply creates exactly that plan and never plans again, so the dashboard always matches the preview. The new dashboard is created private, like a dashboard you create in the app, and you share it from the app.
If the plan fails validation, propose returns invalid_change with a repair hint: name the measures and dimensions to show, or pick datasets with datasetNames, then propose again.
{
"applied": true,
"replayed": false,
"result": { "kind": "dashboard", "id": "d_...", "url": "https://semaphor.cloud/..." },
"warnings": []
}Add a Relationship
Kind semantic_model, operation add_relationship. Adds a relationship between two datasets of a domain, so queries can join them:
{
"target": { "kind": "semantic_model", "domainId": "dom_abc" },
"operations": [{
"op": "add_relationship",
"relationship": {
"sourceDataset": "orders",
"sourceFields": ["customer_id"],
"targetDataset": "customers",
"targetFields": ["id"],
"cardinality": "many_to_one",
"defaultJoinType": "LEFT"
}
}],
"rationale": "Break revenue down by customer segment."
}| Field | Values |
|---|---|
sourceDataset, targetDataset | Dataset names in the domain |
sourceFields, targetFields | One or more field names on each side |
cardinality | many_to_one, one_to_one or one_to_many |
defaultJoinType | LEFT or INNER |
description | Optional |
The relationship is validated when you propose. If a field doesn't exist, propose returns invalid_change and stores nothing.
If someone edits the domain between your preview and your apply, apply returns proposal_stale and changes nothing. Propose again against the current domain. When two proposals target the same domain, the first to apply wins and the other returns proposal_stale.
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 dashboard back, not a second one. After apply_outcome_unknown, follow howToCheck before proposing again, so you don't create a duplicate.
Errors
Change tools return errors as isError: true results with a code:
| Code | Meaning | What to do |
|---|---|---|
write_scope_required | The session doesn't have mcp:write | Log in with write access, or mint a token with mcp:write |
change_not_allowed_for_credential | A token session proposed a semantic model change | Use an interactive login |
invalid_change_target | Unknown kind or operation | Use a kind and operation from the error's list |
invalid_change | The change failed validation; nothing was stored | Fix the request and propose again |
proposal_not_found | Unknown or expired proposalId, or another user's or project's proposal | Propose again |
proposal_stale | The target changed after the preview | Propose again |
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 |
permission_denied | Your permissions don't allow the change in this project, such as creating dashboards | Ask an admin for access |
Permission and Approval
These are two different things.
- Permission is enforced by Semaphor on every call: the
mcp:writescope, your role, the per-kind rule above, and your project permissions. - 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.