Build an Agent
Everything an agent needs to work with Semaphor MCP, in one page you can hand to Claude or any other agent.
This page is written for two readers: you, and the agent you're building. Use Copy Markdown above to give it to Claude, Codex or your own agent as instructions, or point the agent at /llms-full.txt, which holds every MCP page as one Markdown document.
It covers how to connect, the rules the server expects an agent to follow, the four common jobs as complete call sequences, and what to do when a call fails. The Tools Reference has every argument of every tool.
Connect
| Endpoint | https://semaphor.cloud/api/mcp (self-hosted: your Semaphor URL followed by /api/mcp) |
| Transport | MCP over Streamable HTTP |
| Interactive clients | OAuth: the client opens a Semaphor login. See Connect an AI Client. |
| Your own agent or backend | A bearer token: Authorization: Bearer <MCP project token>. See Connect with a Token. |
Mint a token on your server, never in a browser:
{
"projectId": "p_123",
"projectSecret": "ps_...",
"orgUserId": "ou_123",
"mcp": { "scopes": ["mcp:read"], "sql": true },
"tokenExpiry": 3600
}Add "mcp:write" to scopes only for an agent that should save changes. Pass endUserId (and its tenant) instead of orgUserId for an agent that acts as one of your end users, under their security policies.
{
"mcpServers": {
"semaphor": {
"type": "http",
"url": "https://semaphor.cloud/api/mcp",
"headers": { "Authorization": "Bearer ${SEMAPHOR_MCP_TOKEN}" }
}
}
}The server lists only the tools your credential can use: a token pinned to one project has no project list, a session without SQL has no SQL or physical tools, and a session without mcp:write has no apply tool.
What a call looks like on the wire
If you're writing your own MCP client, every tool call is a JSON-RPC tools/call:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "semaphor_get_analysis_context",
"arguments": {}
}
}{
"jsonrpc": "2.0",
"id": 7,
"result": {
"structuredContent": { "recommendedPath": "semantic", "recommendedNextTool": "semaphor_list_semantic_domains", "...": "..." },
"content": [{ "type": "text", "text": "{ ...the same object as JSON... }" }]
}
}Read structuredContent when it's there; content[0].text carries the same result as text. A failed call has "isError": true and a result with a code, a message and usually a fix. Arguments are strict: an unknown argument name fails with an input validation error naming it.
Rules for the agent
Give your agent these rules. The server's own instructions say the same, and every tool description repeats the part that applies to it.
- Start every data question with
semaphor_get_analysis_contextand follow itsrecommendedNextTool. It says whether the project has a semantic model (recommendedPath: "semantic") or only physical connections. - Never guess an ID or a name. Projects, domains, datasets, fields, dashboards, cards and filters all come from discovery tools. A guessed name fails, and the error lists the valid ones.
- Use exact field names from
semaphor_get_dataset_schema. A field of a related dataset is{ "name": "region", "datasetName": "customers" }. - For a question about a dashboard card, run the card as it is:
semaphor_analyzewith onlyfromCard. Build your own query only to ask something the card doesn't show. - Check what the query applied before you answer.
executionResult.statusisanswered,partialorfailed;executionResult.constraintslists the filters and date windows applied, and any skipped with the reason. Mention a skipped filter to the user. - When a call fails, read
code,messageandfix, and change the call. Repeating the same call gets the same error. - Never save a change without the user. Read the outline, call
semaphor_propose_change, show the user itssummarysentences, and callsemaphor_apply_changeonly after they say yes. - Place cards by intent, never by coordinates. Leave out
widthandpositionunless the user names a place or size; the card is then placed by its kind. To tidy a sheet, proposelayout.arrange. - Interactive sessions name the project: call
semaphor_list_projectsand passprojectIdto project-scoped tools. A token session is pinned to its project and doesn't need it.
Job 1: Answer a data question
"What was revenue by region over the last 30 days?"
1. Get the analysis context.
{}{
"projectId": "p_123",
"semanticDomainCount": 3,
"recommendedPath": "semantic",
"recommendedNextTool": "semaphor_list_semantic_domains",
"defaultAnalysisTool": "semaphor_analyze",
"fallbackAnalysisTool": "semaphor_query_sql_advanced",
"semanticDomains": [{ "id": "dom_sales", "name": "sales", "label": "Sales" }]
}2. Find the dataset. semaphor_list_semantic_domains lists domains when there are several; semaphor_list_datasets lists a domain's datasets.
{ "domainId": "dom_sales" }{
"datasets": [
{ "name": "orders", "label": "Orders", "analyticsRole": "fact", "domainId": "dom_sales" },
{ "name": "customers", "label": "Customers", "analyticsRole": "dimension", "domainId": "dom_sales" }
]
}3. Read the fields.
{ "domainId": "dom_sales", "datasetName": "orders", "responseDetail": "compact" }{
"dataset": { "source": { "kind": "semantic", "domainId": "dom_sales", "datasetName": "orders" } },
"fields": [
{ "name": "revenue", "label": "Revenue", "dataType": "number", "modelRole": "fact", "fieldKind": "physical" },
{ "name": "order_date", "label": "Order Date", "dataType": "date", "modelRole": "dimension", "fieldKind": "physical" }
],
"recommendedNextTool": "semaphor_analyze"
}4. Run the query.
{
"domainId": "dom_sales",
"datasetName": "orders",
"measures": ["revenue"],
"dimensions": [{ "name": "region", "datasetName": "customers" }],
"dateField": "order_date",
"timeWindow": { "unit": "day", "value": 30, "anchor": "latest_available" },
"limit": 10
}{
"answerSummary": "Successfully executed query spec (4 rows).",
"data": {
"records": [
{ "Region": "West", "Revenue": 1758699.06 },
{ "Region": "East", "Revenue": 1670049.2 }
],
"rowCount": 4,
"rowLimitExceeded": false
},
"executionResult": {
"status": "answered",
"resultShape": "records",
"fieldsUsed": [
{ "name": "revenue", "label": "Revenue", "role": "measure", "aggregate": "SUM" },
{ "name": "region", "label": "Region", "role": "dimension" }
],
"constraints": {
"applied": [
{ "kind": "filter", "field": { "name": "orders.order_date", "label": "Order Date" },
"operation": "between", "values": ["2026-09-17", "2026-10-17"], "valueType": "date", "timezone": "America/Chicago" }
],
"skipped": []
}
}
}latest_available counts back from the newest date in the data; now counts back from today. The applied filter shows the dates the window resolved to. Add "responseDetail": "compact" for a smaller result that leaves out the full SQL and duplicate metadata.
Job 2: Explain a dashboard card
"Why is the Shipments card down this month?"
1. Find the dashboard and the card.
{ "search": "operations" }{ "dashboardId": "d_ops", "fields": ["outline"] }The outline lists each card's cardId, title, type and query.
2. Run the card as it runs on its dashboard.
{ "fromCard": { "dashboardId": "d_ops", "cardId": "c_shipments" } }{
"answerSummary": "Successfully executed query spec (3 rows).",
"data": { "records": [{ "Facility": "North", "Net Tons": 175079.6 }, { "Facility": "South", "Net Tons": 129574.9 }] },
"executionResult": {
"status": "answered",
"metadata": { "queryApproach": "dashboard_card_replay" },
"constraints": {
"applied": [
{ "kind": "filter", "field": { "name": "shipped_at", "label": "Shipped date" }, "operation": "between",
"values": ["2026-07-21T05:00:00.000Z", "2026-10-19T04:59:59.999Z"], "valueType": "date", "timezone": "America/Chicago" }
],
"skipped": []
}
}
}The card runs with its own filters and the dashboard's default inputs, as a new viewer sees it, under your security policies. To dig further (by week, by another dimension, against last month), build a query from the card's fields with semaphor_analyze. A Matrix card, a text card and a card in a Document section return card_not_supported; build the query from the card's fields instead.
Job 3: Change a dashboard
"Add a bar chart of revenue by region to the Sales dashboard."
1. Read the outline.
{ "dashboardId": "d_sales", "fields": ["outline"] }Check outline.access: canEdit: true means you can change it in place; canCopy: true without canEdit means you propose with "saveAs": "copy".
2. Propose.
{
"dashboardId": "d_sales",
"changes": [{
"op": "card.add",
"type": "bar",
"title": "Revenue by region",
"query": {
"domainId": "dom_sales",
"datasetName": "orders",
"measures": ["revenue"],
"dimensions": [{ "name": "region", "datasetName": "customers" }]
}
}],
"rationale": "The user asked for revenue by region."
}{
"proposalId": "chg_xoK9M...",
"expiresAt": "2026-10-18T21:01:17.322Z",
"risk": "low",
"target": { "kind": "dashboard", "dashboardId": "d_sales", "title": "Sales", "saveAs": "original" },
"summary": ["Adds the card \"Revenue by region\" (bar) to Overview: Revenue by Region. The dashboard's \"Order date\" filter applies to it."],
"warnings": [],
"preview": {
"cards": [{ "ref": "$card_1", "title": "Revenue by region",
"columns": [{ "name": "region", "label": "Region" }, { "name": "revenue", "label": "Revenue" }],
"rows": [["West", 5065026.7], ["East", 4795563.07]], "rowCount": 4 }]
},
"next": "Show the summary to the user. After they confirm, call semaphor_apply_change with proposalId \"chg_xoK9M...\"."
}3. Show the user the summary sentences and any warnings, and wait for a yes.
4. Apply.
{ "proposalId": "chg_xoK9M..." }{
"applied": true,
"replayed": false,
"target": { "kind": "dashboard", "dashboardId": "d_sales", "title": "Sales", "updatedAt": "2026-10-18T20:44:03.114Z" },
"created": [{ "handle": "$card_1", "kind": "card", "id": "c_9f2a", "title": "Revenue by region" }],
"summary": ["Adds the card \"Revenue by region\" (bar) to Overview: Revenue by Region. The dashboard's \"Order date\" filter applies to it."],
"warnings": []
}- If apply returns
proposal_stale, someone saved the dashboard after your preview. Read the outline again and propose again. - If the user changes their mind before applying, propose again with the new changes; an unapplied proposal simply expires after 30 minutes.
- A lost response is safe to retry: call apply again with the same
proposalIdand you get the recorded result withreplayed: true, never a second copy.
Several changes go in one proposal and apply all or nothing. Give a new card or filter a handle such as "$region" to refer to it later in the same list. See Making Changes for every change and its fields.
Job 4: Pivot data
"Show revenue by region down the side and month across the top."
Use semaphor_matrix for a pivot with a field across the top, totals and pages of rows and columns. It takes the same domain, datasets and fields as semaphor_analyze, grouped into rows, columns and measures, and returns one window of the matrix with cursors for the next page in each direction. Its arguments are in the Tools Reference. For a simple breakdown, semaphor_analyze with two dimensions is enough.
When a Call Fails
| You get | It means | Do this |
|---|---|---|
| An input validation error naming an argument | The argument name or shape is wrong | Fix it from the tool's schema; don't add arguments the schema doesn't list |
validation with missing_measure, invalid_dimension or another field code | A field name is wrong | Use one of the candidates the error lists, or read semaphor_get_dataset_schema |
executionResult.status: "partial" | Part of the question was answered | Read coverage.missingObligations and tell the user what's missing |
capability_required | The call needs SQL or physical access the session doesn't have | Use the semantic model, or ask an admin for SQL access |
card_not_supported | fromCard can't run that card | Build the query from the card's fields |
copy_required | You can view the dashboard but not edit it | Propose again with "saveAs": "copy" |
write_scope_required | The session can propose but not apply | Tell the user to apply in the editor, or connect with mcp:write |
proposal_stale | The dashboard changed after the preview | Read the outline again and propose again |
apply_outcome_unknown | An apply never recorded a result | Follow howToCheck before proposing again |
When an agent is stuck, semaphor_plan_analytics_recovery plans the next calls from what it was trying to do and the schemas it has already read.
The Tools
| Tool | Use it to |
|---|---|
semaphor_get_analysis_context | Start a data question: the recommended path and next tool |
semaphor_list_projects | List projects (interactive sessions only) |
semaphor_get_access_context | See who the session acts as and what it can do |
semaphor_list_semantic_domains | List semantic domains |
semaphor_list_datasets | List a domain's datasets |
semaphor_get_dataset_schema | Read a dataset's fields, or a physical table's columns |
semaphor_get_domain_relationships | Read how a domain's datasets join |
semaphor_analyze | Run governed analytics, or a dashboard card with fromCard |
semaphor_matrix | Run a pivot window |
semaphor_plan_analytics_recovery | Get suggested next calls when stuck |
semaphor_list_connections, semaphor_list_databases, semaphor_list_schemas, semaphor_list_tables | Browse physical sources (sessions with SQL) |
semaphor_query_sql_advanced | Run SQL when the semantic model can't express the question (sessions with SQL) |
semaphor_list_dashboards | Find dashboards |
semaphor_get_dashboard_details | Read a dashboard: who can open it, its cards, and its outline for changes |
semaphor_get_dashboard_analysis_context | Read a known dashboard compactly for analysis: its cards, filters, fields and query inputs |
semaphor_propose_change | Preview a change; saves nothing |
semaphor_apply_change | Save a previewed change (sessions with mcp:write) |