REST & SSE
HTTP API
Plain HTTP and JSON, described by OpenAPI 3.1. Replies can wait for the answer or stream as Server-Sent Events.
Connection
- Base URL:
http://127.0.0.1:8765(loopback by default). - Header:
Authorization: Bearer <token>, where the token islocalTokenin<data-dir>/identity.json. - Errors:
{ "code": "SOME_CODE", "message": "..." }with an HTTP status. Missing or wrong token:401 UNAUTHENTICATED.
export CLARKCANT_URL="http://127.0.0.1:8765"
export CLARKCANT_TOKEN="<token>" # localToken from <data-dir>/identity.json
Public routes (no token)
| Route | What it returns |
|---|---|
GET /health | Liveness, runtime platform, negotiated protocol. No node identity. |
GET /.well-known/clarkcant.json | Discovery document listing every surface (api, mcp, websocket, cli) and their endpoints. |
GET /openapi.json | OpenAPI 3.1 description of the stable REST surface. |
GET /connections/callback/{packageId} | Where a provider sends your browser back after you connect a package's account. The single-use state your node issued authenticates it. Answers a short page that never repeats the code or the state. |
Routes (stable v1 surface)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /node | – | Node id, label, fingerprint and configured model (model is null when none is set). |
| GET | /conversations | – | List conversations. |
| POST | /conversations | { title? } | 201 { conversationId, homeNodeId } |
| POST | /conversations/{id}/messages | { text, attachmentIds?, references? } | Waits for the answer; returns { resolution, taskId, messageIds, timeline }, or 202 when the work continues in the background (read the timeline later). |
| POST | /conversations/{id}/messages/stream | { text, attachmentIds?, references? } | Streams the reply as Server-Sent Events (below). |
| POST | /conversations/{id}/stop | { source? } | Stops the reply this conversation is writing, and nothing else. What was already written is kept, labelled as stopped. Returns { stopped }, which is false when no reply was running. A stop is audited with its source (chat, voice, or api when omitted). |
| GET | /conversations/{id}/timeline?after=N | – | Messages (blocks: text/markdown, tool activity, reasoning, widgets…), pins, widget instances. |
| POST | /conversations/{id}/questions/{questionId}/answer | { text?, optionIds?, confirmed? } | Answers an ask_user_question; starts a new turn. |
| POST | /conversations/{id}/questions/{questionId}/cancel | – | Cancels the question. |
| POST | /conversations/{id}/widgets/{instanceId}/export | { sort?, query?, filters?, columns? } | Downloads a table widget as a CSV file (text/csv, named in Content-Disposition). The rows come from the table's own dataset: the sort, search and filters you send choose them over every column the table shows, exactly as on screen, and columns only picks which of those columns the file carries. The request cannot carry rows. A cell a spreadsheet would run as a formula is prefixed with '. Only the table's owner can export it, and the WebSocket relay, MCP and clarkcant api refuse it with 403 PERSON_ONLY. |
| POST | /conversations/{id}/widgets/{instanceId}/actions | { actionBindingId, expectedRevision, expectedBindingDigest, input, invocationId, variant?, sequence? } | Presses a bound action on a widget, the same as a click or a spoken request. 200 is outcome: "done"; 202 is "approval-required" or "background". A refusal carries code and message (English, for logs and agents) and, where it applies, outcome (uncertain, partial, refused), mayHaveRun, recorded, readOnly, taskId, retryAfterMs, limit and a workflow report of each step. A widget that is not in the named conversation gives 404 INSTANCE_UNKNOWN; going over the binding's per-minute limit gives 429 RATE_LIMITED. A call that can change something is written to the effect ledger before it is sent; if that write fails the press gives 503 LEDGER_UNAVAILABLE and nothing is sent. A call that went out without a trustworthy answer (504 SERVICE_TIMED_OUT, 409 SERVICE_CANCELLED, 504 SERVICE_UNREACHABLE, 502 SERVICE_TOOL_FAILED) comes back as outcome: "uncertain" with mayHaveRun: true, becomes an unknown effect, and the inbox asks about it, with a taskId, only when recorded is true. A read that did not finish changed nothing and carries readOnly: true. The same invocationId is never sent twice: a repeat gets the stored answer, even after a restart, and a press a restart cut off gives ACTION_INTERRUPTED. An agent action's contextRefs are read by the node and reach the model only as data, never as instructions; the request carries no text of its own. variant: "view-state" is the state-only write of a host-held player's playback state (canvas.video@1, canvas.audio@1) and needs a sequence, a positive integer that grows with every write; one more than a day past the node's clock is 400 INVALID_INPUT. It takes the same owner, binding, revision, digest and input checks, then answers 200 { variant, duplicate, instanceId, revision, stateRevision, state } with no timeline and without moving the instance revision. A write whose sequence is not newer than the last one accepted writes nothing and, before the revision and digest checks, is answered duplicate: true with the state the node holds now; a retry of the latest write's invocationId is answered that way as is, and any other such write also carries stale: true. An invocationId starting with view-state: is reserved for the node's own records and gives 400 INVALID_SCHEMA, as do a sequence without the variant, the variant without one, or any other variant; the variant on any other binding gives 400 UNSUPPORTED_ACTION. |
| GET | /composer/suggestions?trigger=/|@&q=&conversationId= | – | What to offer after / (skills) or @ (projects, files, folders, services, conversations, background work). At most 8 rows; each carries the ref to send, or a disabledReason. See below. |
| POST | /stop | – | Emergency stop: kills running commands, interrupts turns, stops background work. |
Start a conversation
curl -s -X POST "$CLARKCANT_URL/conversations" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title": "From my app" }'
# 201 → { "conversationId": "…", "homeNodeId": "…" }
Send a message and wait
curl -s -X POST "$CLARKCANT_URL/conversations/<conversationId>/messages" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "text": "Summarise the notes in my Documents folder" }'
# → { "resolution": …, "taskId": "…", "messageIds": […], "timeline": … }
A typed sentence like “open settings” is resolved as an app intent by the host (appIntent in the response) instead of reaching the model.
In the composer, a selected file stays with its draft while the upload is checking. Send is disabled during that time; pressing Enter does not submit or clear the message or file. Once the upload finishes, send the message normally. A failed file keeps its stated reason and is not included with the message.
The composer marks basic Markdown as you type: bold, italics, strikethrough, inline code, links, headings, quotes, lists and fenced code blocks. The field stays plain text, so typing, selecting, undo, spell-check and input methods work as before. The Markdown characters stay visible, and the message is sent exactly as you typed it.
The line under the composer starts with the model the next message runs on and its thinking level (thinking: default when the node names none), then how full the context is, the cache hit rate, the writing speed and the cost of the last turn. It reads the model from GET /node, whose model names a model picked in Settings since the node started, and it updates as soon as you pick another one. A single turn stops after 5 minutes by default (CC_MODEL_MAX_WALL_CLOCK_MS); longer work belongs in background work.
Approvals are decided by a person on their own surfaces. That route is outside the stable description and is deliberately not exposed as an MCP tool.
A message sent while Clark is answering
A short follow-up you type while Clark is still answering joins that answer. A message with files, references or an approval, a spoken message, or one from a script or another app waits and gets its own answer. Stop also cancels messages still waiting.
On the plain /messages route, a message that arrives while a turn is answering is decided there: it joins the running turn (a steer), interrupts it, or runs in the background. A message whose origin differs from the running turn's, or a typed message during a spoken turn, is never steered into it. When a steer was chosen for such a message, it does not interrupt the running turn either: it waits and is answered as a turn of its own, with its own origin. A running turn is interrupted only when the decider chose that, when the new message carries references, or when a background run has no worker to take it.
Every other way a turn starts (the streaming route, an approval's continuation, an answered question, voice) never sends a running turn a second prompt. Bare text from the same origin and channel, with no attachments, references, guidance or data, is steered into the turn while its reply is being written. That reply answers it, and the response and the stream's done event report resolution: "steered" with no message ids. Typed words never join a spoken turn, and a message with attachments waits for its own turn. Anything else waits for the running turn to end and then becomes a turn of its own, reading its own attachments and references; its request (or stream) stays open until that turn has answered.
A turn that is only being set up (its session still being created, or a model switch still pending) is not answering yet: a new message never stops it and is answered after it. A Stop, the emergency stop or the node shutting down cancels every message still waiting, so none of them starts; its reply card says it was stopped before it started, and the message is still saved.
Point at a skill, a project or a file
A message can carry up to 8 typed references beside its text: a skill, a project, a file or folder inside an indexed project, an MCP service, another conversation or a piece of background work. Ask GET /composer/suggestions for them rather than building them by hand, and send the ref of each row you choose. The app's inbox sends one more kind the same way, a notice, when you use Ask Clark or Add to context on it.
curl -s "$CLARKCANT_URL/composer/suggestions?trigger=@&q=myapp/src/" \
-H "Authorization: Bearer $CLARKCANT_TOKEN"
# → { "trigger": "@", "query": "myapp/src/", "suggestions": [ { "kind": "file", "label": "myapp/src/app.ts", "ref": { … } } ] }
curl -s -X POST "$CLARKCANT_URL/conversations/<conversationId>/messages" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "text": "Review @myapp/src/app.ts", "references": { "version": 1, "items": [ <the ref> ] } }'
A reference is a pointer, not a permission. The node checks each one again when the message arrives: a skill that was edited or removed, a file that is gone, a project outside the approved folders, a conversation that no longer exists or a notice no longer in the inbox refuses the whole message with 400 REFERENCE_NOT_AVAILABLE, naming the reference, and nothing is stored. An accepted reference is stored on the message as a reference block, and the turn is told about it by paths relative to the approved folder, never absolute ones; a notice's own words reach it quoted as data, never as instructions. Reading, running or changing what it points at still goes through the usual tools and policy.
Stream the reply (SSE)
curl -N -X POST "$CLARKCANT_URL/conversations/<conversationId>/messages/stream" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "text": "What changed in my project today?" }'
| Event | Meaning |
|---|---|
delta | More reply text: { text }. |
reasoning | Reasoning from the model, kept apart from what it says to the user. |
tool-start, tool-end | A tool call began or finished. |
host-control | The agent asks the app to change something: open Settings on a tab, switch the model, open or end voice mode, go back to the conversation. See below. |
error | The turn hit an error. |
done | Final event: { resolution, taskId, messageIds, timeline }. |
From JavaScript:
// EventSource cannot POST or send headers, so read the stream with fetch.
const res = await fetch(`${url}/conversations/${conversationId}/messages/stream`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
console.log(value); // raw Server-Sent Events: delta, tool-start, …, done
}
When the agent changes the app
A host-control event the agent asked for carries a controlId. The app carries the action out, then answers with POST /app-intents/host-control/<controlId> { "ran": true|false, "say": "…" }. The agent is told that answer, so Clark says the app changed only when the app reports it did, and passes on the app's reason when it could not. With no answer within a few seconds, the agent is told the action is unconfirmed. Each controlId is answered once, and the waiting-for-the-answer route (/messages) never waits for it. Answering is a person's surface: the WebSocket relay, MCP and clarkcant api refuse it with 403 PERSON_ONLY.
In the audit record, an action the agent asked for while answering something you said is marked voice-agent, separate from a command you spoke yourself (voice).
Read a conversation
curl -s "$CLARKCANT_URL/conversations/<conversationId>/timeline?after=0" \
-H "Authorization: Bearer $CLARKCANT_TOKEN"
The timeline returns every step as stored. When the app shows a reply, three or more consecutive working steps (tool calls, reasoning and the checks under them) fold into one line, “Worked through N steps”, which opens on click onto every step in order. If a step failed, the line adds “· N failed” in red, and inside, the failed step opens on its reason. While a reply is still being written, its newest step stays outside the fold.
After you approve a command on a card, Clark starts a new turn so it can read what the command returned and carry on. Nobody typed the user message that turn answers, so it carries hostWritten: { "kind": "host-continuation", "version": 1 }. Show such a message as a line from ClarkCant ("Approved — Clark carries on"), not as words the person wrote, and do the same for any kind or version you do not recognise. Its text is what the model read. It does not appear in search results, and clarkcant read prints it as (approved, Clark carries on).
Answer Clark's questions
When Clark needs something from you it asks with ask_user_question and ends its turn. Your answer starts a new turn. Send text, optionIds or confirmed, whichever the question calls for, or cancel it with …/cancel.
curl -s -X POST "$CLARKCANT_URL/conversations/<conversationId>/questions/<questionId>/answer" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "optionIds": ["<optionId>"] }'
Approvals raised by a running task
GET /inbox includes waiting approvals raised by running tasks. Decide one with POST /tasks/{taskId}/approvals/{approvalId}/decide and { "decision": "granted" | "denied", "digest": "<operationDigest shown in the inbox>" }. Granting authorizes the task to run again; redispatched says whether the node accepted that run. If it did not, nothing has run and you can ask again. Denial or expiry ends the task. This is a person-only decision: MCP, WebSocket request relays and clarkcant api refuse the route with 403 PERSON_ONLY, so an AI client cannot approve its own action.
Who asked: a turn's origin
Every turn records who started it, as the user message's origin: person (your own page, a spoken message, or a card you answered or a widget action you pressed in the page), mcp (MCP ask_clark), relay (the WebSocket relay), cli-api (clarkcant api, a script or any other token holder), automation (a scheduled or standing automation) or peer (a task another node delegated). The node reads it from what the gateway already knows, never from the request body, and writes it on the approval card the turn causes, on its row in GET /activity and in the audit log.
By default the policy treats every origin like the person: a turn an AI client started over MCP is decided exactly as your own message is. To have programs ask first, opt in under Settings → Control → Requests from other programs, which sets the execution.machineTurns preference to "ask". Then a risky step (external-write, destructive, financial, communication, media-capture) in a turn started from mcp, relay or cli-api asks first, where the policy would otherwise have run it. A deny rule or a prohibition still wins.
Approving one step does not approve the rest: after you approve a card a program raised, its next risky step is asked about again. Note that a program holding this node's token can still change this setting, or call the HTTP API directly as you.
Act on a notice
GET /inbox lists your notices, and each one carries the actions that fit what it names right now. Your node works them out on every read, so an action that can't be taken any more is still listed, with an unavailable reason (conversation-gone, work-gone, package-gone or already-current), instead of a button that fails. Each action goes to a route of its own:
| Action | Route | What it does |
|---|---|---|
| Run again | POST /work/{id}/retry | Background work that failed, was stopped or was cut off by a restart starts again with the same request, in the same conversation, as new work with its own id. It runs once per run: a second press answers 409 ALREADY_RETRIED. Work that finished or is still running answers 409 WORK_NOT_RETRYABLE, and a busy node answers 429 BACKGROUND_BUSY, leaving the run free to be tried again. |
| Ask again | POST /conversations/{id}/questions/{questionId}/ask-again | A question that expired with nobody answering comes back as a new question with the same words and choices, and the old card says it was asked again. A question still waiting answers 409 QUESTION_OPEN, one answered or cancelled answers 409 QUESTION_CLOSED, and one already asked again answers 409 ALREADY_ASKED_AGAIN. |
| Update | POST /packages/install | The ordinary install with the version the notice names, so every install check still applies. If it's refused, the version you have stays installed. |
| Skip this version | POST /inbox/notices/{id}/skip-version | Stops reporting that version of a package, and older ones; a newer version is still reported. The version is read from the notice, not from the request. …/unskip-version takes it back. Any notice other than an update answers 409 NOT_AN_UPDATE. |
A notice that Clark's engine (the Pi SDK) has a newer version asks nothing of you: the new version arrives with the next ClarkCant release. It is titled after Clark's engine, its body keeps the Pi SDK's name and versions, and it leads with Dismiss, with Skip this version beside it and no Ask Clark button.
The versions you skipped are listed in skippedVersions on GET /inbox, newest first. DELETE /inbox/skipped-versions/{kind}/{name}/{version} takes one back even after its notice is gone, with kind set to package or pi and each part URL-encoded, so @scope/name works.
curl -s -X DELETE "$CLARKCANT_URL/inbox/skipped-versions/package/%40scope%2Fname/1.4.0" \
-H "Authorization: Bearer $CLARKCANT_TOKEN"
One route for any of a notice's actions
POST /inbox/notices/{id}/actions/{action} carries out any of these by name, and it is the route the app's inbox buttons, a typed or spoken request, Clark's own agents, the MCP tool act_on_notice and clarkcant api all use. The action is one of mark-read, mark-unread, dismiss, restore (undoes a dismissal within five minutes), snooze, unsnooze, suppress, unsuppress, retry, update, skip-version or ask-again. The body is {}; snooze needs { "until": "<ISO instant>" }, at most 30 days away.
curl -s -X POST "$CLARKCANT_URL/inbox/notices/<noticeId>/actions/dismiss" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
Your node checks the action against what the notice offers at that moment and changes nothing when it refuses:
400 UNKNOWN_ACTION: the name is not a notice action. The action is read exactly as written in the path and never decoded, so a percent-encoded name is refused here.403 PERSON_ONLY:reconcile-confirmedandreconcile-failed. These are your answer about an action nobody saw finish, and they have their own route (below).403 PERSON_ONLY:updateasked for by Clark's agents, by MCP or through the WebSocket relay. Installing an update is your own decision, made with the notice's Update button, soclarkcant api, the relay and MCP'scallrefuse this route too.409 SURFACE_ACTION:open,ask-clark,add-to-context,review-updateandcopy-details. They change what your screen shows or holds, so only the app does them.404 RESOURCE_NOT_FOUND: the notice is gone.409 ACTION_NOT_OFFERED: the notice does not offer the action now.409 ACTION_UNAVAILABLE, with theunavailablereason: the notice lists the action, but it can't be taken now.409 ACTION_IN_PROGRESS: that notice's update is already being installed.409 UNDO_EXPIRED:restorecame more than five minutes after the dismissal.
When it works, the answer is { "noticeId", "action", "outcome": "done" }, plus whatever the action produced: snoozedUntil, the new workId, the version or the new questionId. An installed update also says how many of the permissions it asked for wait for your approval (pendingCapabilities) and how many your execution mode refused (deniedCapabilities). update passes on any refusal from the install unchanged. If your execution mode asks before installing, it answers 202 with "outcome": "approval-required" and installs nothing; asking again for the same version reuses the approval that is still waiting. Each call this route handles is recorded in your node's audit with the surface it came from. The routes in the table keep working as before.
The inbox routes take the same token but are not in /openapi.json yet and may change.
Say whether an action took effect
When a command that reaches outside your machine, such as a push, runs out of time or is stopped before it reports, nobody knows whether it went through. The task waits as uncertain, does not try that action again on its own, and your inbox gets one notice about it with two buttons: “It took effect” and “It did not take effect”. The answer is recorded with who gave it and when, cannot be changed afterwards, and settles the task: it succeeded only when the action took effect and the run had checked its result.
curl -s -X POST "$CLARKCANT_URL/effects/<effectId>/reconcile" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "outcome": "confirmed" }'
# outcome: "confirmed" (it took effect) or "failed" (it did not)
source (click, chat or voice) is an optional label for where the answer was given; it is stored as sent and proves nothing. An effect of another person's task, of another node, or one that does not exist answers 404 RESOURCE_NOT_FOUND; one that is no longer unknown answers 409 EFFECT_NOT_UNKNOWN. This records a person's decision, so the WebSocket relay, MCP and clarkcant api refuse it with 403 PERSON_ONLY: an AI client that could say its own push landed could report its own success.
A background website task writes its form submits and other clicks that could change something to the same ledger. When a submit went out and no answer came back, the effect is unknown, the form is not sent again, and it leads here.
Choose a theme
A theme sets Clark's colours and corner radii, and from appearance API 2 the rest of the look (see below). It is separate from the colour scheme (light, dark or follow the system), which still applies inside every theme. Clark Default is built in, and more themes come from installed packages that declare a themes facet. A theme is only data: it can't run code, add global styles, reach outside its package or fetch anything. Every theme is held to the same contrast check as Clark Default and to a check that keeps protected states apart, and one that fails either isn't offered. These routes take the same token but are not in /openapi.json yet and may change.
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /themes | – | { themes, problems, unchecked }. Clark Default comes first, then each package theme with its themeRef (package:<package id>#<theme id>) and its provider: package id, version, digest, trust lane and source. problems names each theme that can't be offered and why; one that failed the contrast check carries a contrast list of the failing pairs. unchecked names each package whose files this node couldn't read. |
| GET | /appearance | – | { selectedRef, appliedRef, theme, provider, fallback }: what you chose and what is drawn. When the choice can't be drawn, Clark Default is drawn instead, your choice is kept, and fallback says why with THEME_NOT_INSTALLED, THEME_INVALID, THEME_LOW_CONTRAST, THEME_PROTECTED, THEME_UNAVAILABLE or THEME_UNKNOWN. Reinstalling the package, or rolling back an update that broke it, brings the theme back without choosing it again. |
| PUT | /preferences/experience.themeRef | { value } | Chooses a theme: builtin:clark, or a themeRef from /themes. A reference this node can't draw is refused with 409 and the same code and reason, and nothing is stored. A value that isn't a reference at all answers 400 PREFERENCE_INVALID. |
message fields are English, for logs. Show a failure from its code and contrast in your reader's language, as the app does. Nothing is pushed when a package changes, so a client re-reads /appearance after it changes a package and when its window comes back into view. The page restyles in place, without a reload, and keeps the conversation, the message being written and pinned views as they were.
curl -s -X PUT "$CLARKCANT_URL/preferences/experience.themeRef" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "value": "package:com.example.theme-dusk#dusk" }'
Reference theme packages
Pixel Arcade and Neo Brutalism are data-only examples in the ClarkCant checkout. Install them through the existing package lifecycle, then choose them in Settings → Experience or preview them in Theme Lab. Their references are package:org.clarkcant.pixel-arcade#pixel-arcade and package:org.clarkcant.neo-brutalism#neo-brutalism. They are checkout packages; marketplace publication remains a separate step.
Pixel Arcade uses square frames, beveled controls, hard shadows, bounded scanlines, stepped motion and a Plasma Orb default. Neo Brutalism uses thick borders, offset shadows, strong headings, raised controls and a Glass Orb default. Both adapt through the same host, built-in/declarative widget, isolated-widget snapshot and detached-window paths. Personal Orb choices override these defaults; reduced motion always wins. Clark Default stays unchanged.
Each package records Apache-2.0 licensing and its source. Fonts are host-owned family profiles with system fallbacks: Pixel Arcade uses mono display, typewriter code and readable system body; Neo Brutalism uses system body/display and the host mono stack. The packages bundle no font files and fetch none. Faces from system stacks may vary across operating systems.
Preview and customize
Open Settings → Experience → Browse themes for Theme Lab. It uses real production components with explicitly labelled local examples. Previewing an installed theme preserves your conversation, draft and focus; only Use this theme stores the choice. Recently used themes are kept by the same preference writer (up to six distinct references), and entries absent from this node are omitted from the recent buttons.
GET /appearance?themeRef=<encoded reference> resolves a checked installed theme for preview without changing preferences. A malformed reference answers 400; an unavailable theme returns the usual Clark Default fallback. Personal appearance is returned as optional customization: { accent, density, font, codeFont }. Accent is null for the theme's colors or { dark: "#7AA2F7", light: "#2453A8" }; density is comfortable or compact. font is the interface face for messages, headings and controls, one of the body profiles below; codeFont is the face for code blocks, inline code, paths and aligned figures, one of the mono profiles. null keeps the theme's face. Write these through PUT /preferences/experience.accent, experience.density, experience.font and experience.codeFont with { value }. In Settings → Experience each font choice is drawn in its own face, so you pick by looking. Each accent shows as a swatch beside its hex code, and the swatch opens the system colour picker, so you can choose a colour without knowing its code. An unreadable accent or one hiding protected states answers 409 before any preference changes. The compiler chooses readable text on accent buttons; compact spacing keeps the typography and layout minima. Both choices reach widgets and detached windows through the same appearance snapshot.
If a package update makes a previously saved accent fail its audit, the theme's accent is drawn and customizationFallback explains why; the saved preference is kept. Reset theme customization returns accent, density, fonts, motion and Orb personalization to their defaults while preserving the selected theme, color scheme and language. The operating system's reduced-motion choice always wins. See theme authoring for init, dev, test and pack.
The rest of the look (appearance API 2)
From appearance API 2 ("appearanceApi": { "min": 2, "max": 2 }), a theme can set the whole look, not only colours. Each setting is a name or a bounded number, and ClarkCant writes the CSS:
typography:bodyanddisplayfromclark,system,serif,rounded,mono,inter,geist;monofromclark,typewriter,jetbrains,geist-mono;headingWeight400–800. ClarkCant ships Inter, Geist, JetBrains Mono and Geist Mono itself, so they look the same on every system; the other profiles are system font stacks.border:width1–3 px,stylesolidordashed.shadow:stylesoft,hardornone. For a hard shadow,offset1–8 px andcolortext,borderoraccent.motion:speed0.5–2,easingstandard,snappy,linearorstepped.icons.stroke: 1–2.5.radius.field: 0–2 rem.recipes:buttonquiet, outlined, solid, raised or beveled;cardflat, outlined or raised;inputquiet, filled, outlined or underlined;modalfloating or framed;badgepill, rounded or square;composerfloating, integrated or framed.effects:backdrop.kinddot-grid, hard-grid, scanlines, grain or paper, withintensity0–1 andscale8–48 px;surface.kindglass, soft-glow, paper or grain, withintensity0–1. The light that follows the pointer over the backdrop scales with the backdrop's intensity and is never brighter than Clark's own. Glass tints widget cards and the composer with an opaque frost; only the modal is translucent and blurred.orb: a default Orbprofileand an optionalpaletteof 0–1 colour triples for every Orb channel exceptcanvas, which the page always supplies. It applies only until you choose an Orb yourself.
A theme can't contain CSS, selectors, URLs, font files or images, and an unknown field, recipe or effect, or a value out of range, is refused. A theme must pass two checks in both light and dark: the contrast check and the protected-state check. The protected-state check requires that:
- danger, warning, success and the accent stay distinct, and each status stays distinct from body text;
- the focus ring is distinct from edges, and disabled text is distinct from enabled text;
- card edges stay visible on the card and on the page;
- text, status colours, the accent and the focus ring stay readable on every surface an effect finishes, and on the page under the backdrop and its pointer light. The modal is measured over the brightest and the darkest page its scrim can cover, so strong glass or a dense lit pattern is refused;
- the Orb's light stays visible against the page, so a palette dark enough to hide it is refused.
A theme that fails isn't offered. /themes lists it under problems with each failing check, and choosing it answers 409 with THEME_LOW_CONTRAST (with a contrast list) or THEME_PROTECTED (with a protected list of { scheme, check, first, second, value, minimum }). check is status-distinct, status-vs-text, focus-vs-border, disabled-distinct, edge-visible, surface-readable or orb-visible, and first is "orb" for orb-visible. THEME_PROTECTED can also be the fallback code of /appearance. Word each code in your reader's language, and word a code you don't know with a generic sentence.
Some things always stay under ClarkCant's control, whatever the theme says:
- focus rings and disabled controls;
- the host's own approval, credential and connection cards: their edge and their plain surface, with no glass, texture, glow or shadow;
- the buttons in those cards and in the inbox, and Stop. They are ClarkCant's own buttons in the theme's colours, so an approval's Approve stays filled with the accent and its Deny stays plain beside it;
- reduced motion, whether it comes from the operating system or from Settings → Experience → Motion → Reduced. Every duration is then none, the backdrop's pointer light is off and the Orb is still.
Change the look by asking
You can also change the look by asking Clark, by voice, or through POST /app-intents with appearance.set-theme (themeRef), appearance.set-color-scheme (colorScheme: light, dark or system), appearance.reset (Clark Default, following the system) or appearance.open-theme-gallery. Each one stores your choice the same way the Settings picker does, and none of them asks for confirmation. A theme is checked against this node's themes and refused with a sentence when it can't be drawn. A request for work, such as "make a dark theme", goes to Clark rather than switching the scheme. The built-in phrases ("switch to dark mode") keep their meaning even if an installed theme is named "Dark"; a theme like that is chosen from the picker or by the agent.
Appearance for widget authors
Built-in widgets and declarative Mini Apps follow the active theme through semantic tokens. An isolated widget receives the same checked public AppearanceSnapshot, with the resolved light/dark scheme, bounded tokens, reduced motion and a revision. It receives no raw theme file, credentials or host access.
api.appearance.current() returns a deeply frozen snapshot, or undefined when an older host did not offer the extension. api.appearance.subscribe(handler) follows changed revisions and returns an unsubscribe function. The initial snapshot arrives before mount; changing the theme keeps the iframe, props and state, and sends no semantic write or model turn. Historical content, captured props/state, provenance and fallback text stay intact.
import { bindAppearance } from "@clarkcant/widget-sdk/dom";
const unbind = bindAppearance(document.documentElement, api.appearance);
api.lifecycle.onDispose(unbind);
The SDK core is independent of the DOM. The optional adapter writes only canonical --cc-* variables and appearance attributes on the supplied element, preserving unrelated author variables. The host-served /widget-runtime.js also exports bindAppearance and applyAppearanceToElement.
Bridge version 2 carries optional appearance in init with appearance@1, then sends { kind: "appearance.changed", nonce, revision, appearance }, with matching revisions and the existing source/nonce checks. The new SDK accepts version-1 hosts without appearance. A bundled old version-1 SDK must be upgraded for a version-2 host; use the host-provided runtime or rebuild with the current SDK. Detached compositions receive the same resolved revision through the desktop's read-only relay, without fetching a theme or receiving credentials.
Widget definitions default to appearanceMode: "adaptive"; a widget may declare "fixed" for its own visual system. Lab and detail cards identify fixed widgets. Directory entries may provide widgetAppearance: [{ id, mode }] for marketplace disclosure, but the installed, digest-checked definition remains authoritative. Fixed widgets still receive appearance and must respect reduced motion; neither mode can alter host chrome.
Map tiles
Maps draw an offline basemap and request no tiles until a tile provider is named in the preference maps.tilePolicy, which is null by default (see Maps). You set it in Settings → Extensions → Map tiles, or Clark sets it through its tool under your execution policy. Writing or undoing the policy directly, and entering or removing the key, are yours alone: MCP, the WebSocket relay and clarkcant api refuse those routes with 403 PERSON_ONLY. The tile routes are in /openapi.json.
| Method | Path | Body | Notes |
|---|---|---|---|
| PUT | /preferences/maps.tilePolicy | { value } | Sets the policy to one provider, or to null to turn tiles off. origin is exactly scheme://host[:port], https, or http only for a loopback address. template is a path on that origin with {z}, {x} and {y} once each. attribution is one line of at most 200 characters, and maxZoom a whole number up to 19. The template is at most 300 characters, starts with / and uses only letters, digits, / . _ ~ - = & ? and the placeholders. In the template's query, a parameter name containing key, token, secret, sig, auth, pass, credential or session is refused: the key goes in credential. The optional credential is { secret: "maps:tiles" } with exactly one of header or query; a policy naming any other secret is refused. A value that does not fit answers 400 PREFERENCE_INVALID, and nothing is stored. Person-only: 403 PERSON_ONLY on a machine surface. |
| POST | /preferences/maps.tilePolicy/undo | – | Undoes the last write and returns to the value it replaced: { undone, preference }. undone: false, with a reason, means there was nothing to undo. Person-only: 403 PERSON_ONLY on a machine surface. |
| GET | /map-tiles | – | { provider: { origin, attribution, maxZoom } | null, offline? }. When provider is null, offline says why: no-provider (the default), key-unavailable (the policy needs a key and no usable key is saved) or key-origin-mismatch (the saved key was entered for another origin, so the node does not send it to this one). Never the path template or anything of the key. |
| GET | /map-tiles/key | – | { key: { origin? } | null }: whether a key is saved, and the one origin it is sent to. Never the value. |
| PUT | /map-tiles/key | { origin, value } | Stores the key as the node secret maps:tiles, bound to origin; the node sends it only there. Entering it again for another origin moves the binding, and nothing else does. Answers { key: { origin } }, never the value. Person-only: 403 PERSON_ONLY on a machine surface. |
| DELETE | /map-tiles/key | – | Removes the key: { removed }. Person-only: 403 PERSON_ONLY on a machine surface. |
| GET | /map-tiles/{z}/{x}/{y} | – | One tile from the policy's provider, fetched by the node: image/png or image/webp, checked by the provider's type and the bytes, at most 512 KiB, with nosniff. Redirects are not followed. Refusals: 404 MAP_TILES_OFF with no policy, 400 MAP_TILE_OUT_OF_BOUNDS for a zoom above maxZoom or x and y off that zoom's grid, 429 MAP_TILES_RATE_LIMITED above 12 a second with a burst of 48, 404 MAP_TILE_MISSING when the provider has no tile at that address, 502 MAP_TILE_FAILED or MAP_TILE_REFUSED, and 503 MAP_TILE_KEY_UNAVAILABLE when the key cannot be used, usually with the same offline reason (not when the secret broker itself refuses the key). Up to 256 tiles or 24 MiB are cached for an hour. |
The key is always the node's own secret maps:tiles, whose only consumer is maps:tiles@<origin>, the origin it was entered for. The node adds it, through the secret broker, as the header or query parameter the policy names, and only to requests to that origin. Clark can name a provider but cannot move the key, and the generic credential form (POST /credentials) refuses the name maps:tiles and any maps:tiles consumer. The key never reaches the page, props, widget state, logs, the cache key, an error message or the model.
The example below calls the node over direct HTTP with the node's own token. clarkcant api, MCP and the WebSocket relay refuse the same call with 403 PERSON_ONLY.
curl -s -X PUT "$CLARKCANT_URL/preferences/maps.tilePolicy" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "value": { "origin": "https://tiles.example.com", "template": "/styles/basic/{z}/{x}/{y}.png", "attribution": "© Example contributors", "maxZoom": 17, "credential": { "secret": "maps:tiles", "header": "x-api-key" } } }'
Media content policy
The audio player can play a file from the web, but the page never fetches it: your node does, once, when the player is placed (see Audio player and document preview). There is no HTTP route for it; the policy is the node's start-up setting CC_MEDIA_ORIGINS, a comma-separated list of bare https origins, at most 32, empty by default. A list with any entry that is not one is ignored as a whole, the node allows nothing, and it says why once at startup.
CC_MEDIA_ORIGINS=https://media.example.com,https://cdn.example.org
Each refusal ends with (media policy rule: <rule>) in the message the model reads:
| Rule | What is refused |
|---|---|
origin-not-allowed | An origin not listed in CC_MEDIA_ORIGINS. |
https-only | Any scheme but https, in the URL or in a redirect. |
credentials-in-url | A user name or password in the URL or in a redirect. |
private-address | A name that resolves to a loopback, private, link-local or other reserved address, checked on the address actually dialled. An origin written as an address or as localhost names that address and is allowed. |
redirect-off-origin, too-many-redirects | A redirect to another origin, or more than 3. |
type-not-allowed | A declared type other than audio/mpeg, audio/ogg, audio/wav or audio/webm. |
type-mismatch | Bytes that are not the declared type, checked by sniffing the container, or a compressed transfer. |
too-large | More than 25 MiB, from the declared length or as soon as the body crosses it. |
too-long, duration-unknown | More than an hour, or a container that does not state its length. |
timeout, not-found, fetch-failed | A fetch longer than 30 s, a missing file, or one that could not be reached. |
source | Not exactly one source, or an id the node does not issue. |
The request carries no cookie, authorization or referrer and uses a fresh connection. A file that passes is stored as an artifact of the conversation against your storage quota, and the page plays it from the node; its content policy stays media-src 'self' blob:. The same checks of type, size and length apply to an audio file the person already holds.
Install a package
POST /packages/install installs the package you chose. Only your own app can call it: MCP, the WebSocket relay and clarkcant api get 403 PERSON_ONLY, and no agent or model tool can install a package.
For a package listed by a path on this machine, your node digests its files itself; you don't send a digest. The marketplace listing carries contentDigest, the digest of those files when the list was made, and the Install button sends it back. If the files changed since, the install is refused with 409 DIGEST_MISMATCH, nothing is installed or asked, and the app offers to search again instead of the same Install. The install copies the files into your node's package cache and the package runs from that copy, so later edits to its files change nothing until you install it again, and that install checks them anew. While you work on a package, clark widget dev is the live editing loop. If the listing changes under an installed package (the same version listed with another digest, or another version), its files, frames and widget reads answer 409 NOT_INSTALLED until you install it again, and its widgets keep their state. A path whose files can't be copied (unreadable, containing symbolic or hard links, or more than 5,000 files, 5,000 folders, folders 64 deep or 64 MiB) is refused with 400 LOCAL_SOURCE_UNREADABLE, and files that change while they are being copied give 409 DIGEST_MISMATCH, so a copy is never a mix of two versions. A copy your node can't write into its own cache (a full disk, for example) is refused with 503 PACKAGE_CACHE_UNAVAILABLE; your files are not changed, and you can try again. These checks run before your execution mode decides. Clients that send localDigest keep working as before.
When your execution mode asks before installing, your node installs nothing and answers 202 with { "code": "APPROVAL_REQUIRED", "message", "approvalId" }. The install then waits in the inbox: GET /inbox lists it under waiting as kind: "install-approval", with the package, its version, what it asks for, its risk tier, an operationDigest, requestedAt and expiresAt. It is listed only while the directory still publishes that exact package, version and digest.
You decide it with POST /packages/approvals/{approvalId}/decision and { "decision": "granted" | "denied", "digest" }, sending the digest the item showed. This is the same person-only route that decides a package's capability approvals.
- Denied: nothing is installed, and your packages stay as they were.
- Granted: your node runs the same install, with every install check, bound to that digest.
A granted decision can still be refused:
409 DIGEST_MISMATCH: the listing changed after the question. The question stays open, and nothing is installed.409 DIGEST_MISMATCH, for a package listed by a path on your machine: its files changed after the question. Nothing is installed and the question leaves the inbox. Install it again to be asked about the files as they are now.400 LOCAL_SOURCE_UNREADABLE: the files at a local path can't be read, so the question is refused before it is asked.403 POLICY_REFUSED: your execution mode now forbids installing.APPROVAL_FORGED: the digest sent is not the one that was asked about.APPROVAL_ALREADY_DECIDED: the question was already decided, so a second press never installs twice.APPROVAL_EXPIRED: the ten minutes have passed.
An install nobody decides expires after ten minutes. Nothing is installed, and a notice says so. Every outcome (asked, installed, denied, expired, refused, failed) is recorded on the package.install-approval event stream. A notice's Update and the marketplace's Install both lead to the waiting item and say where to decide it.
Before you install, the directory card and the install question list what the package reaches outside its sandbox (see what a package reaches). An artifact whose manifest declares other reach than its listing showed is refused with 409 DECLARED_REACH_MISMATCH before anything is recorded.
Stop everything
curl -s -X POST "$CLARKCANT_URL/stop" \
-H "Authorization: Bearer $CLARKCANT_TOKEN"
Delete a conversation
Say “delete this conversation”, or ask by voice. Clark uses your current execution policy: it can carry out your explicit instruction, ask you first, or refuse. When policy asks, the conversation shows Keep conversation and Delete conversation. There is no Undo copy: deletion frees retained files and quota.
POST /conversations/{id}/delete accepts {} on the conversation's home node. It is person-only: MCP, the WebSocket relay and clarkcant api refuse this route and its confirmation with 403 PERSON_ONLY; agent app intents cannot delete either.
202returns{ deleted: false, decision: { kind: "needs-confirmation", intent, readBack, confirmationToken } }when policy asks. Nothing is deleted.- Answer with
POST /app-intents/confirmand{ confirmationToken, decision: "granted" | "denied" }. Denial keeps everything. Approval returns a decision carryingintent.deletionPermit. - Send
{ deletionPermit }to the original delete route. This permission belongs to that person and conversation, expires after two minutes and works once. Current policy and activity are checked again; a new refusal wins over an earlier approval.
200 returns { deleted: true, conversationId, attachments, artifacts, pendingFiles, readBack }. Conversation-owned rows, attachments, widget files (including finished files never attached), grants and cleanup jobs commit together, with foreign keys enabled. Physical files are removed after commit; bytes still used elsewhere remain. Locked files stay queued for the next start or periodic sweep, and pendingFiles reports how many are waiting.
409 returns { deleted: false, decision: { kind: "refused", say } }, explaining what was kept and what to do next. Unfinished, paused or uncertain tasks, running turns, background work and widget actions still settling block deletion. Finish, cancel or reconcile that work before trying again.
Saved memory, independent resources, session logs and append-only audit/replication history are kept. After confirmed success, the app starts a fresh conversation. If the network reply is lost, the result is unknown: reload before retrying.
Files a widget holds
A widget never gets a path. It holds a file as an artifactRef, { v, artifactId, kind, mimeType, sizeBytes, name, digest? }, and your machine checks the widget's grant again every time the ref is used, so a ref copied elsewhere opens nothing. A grant lasts 24 hours from the pick or the widget's last write. After that a widget still reads a file it made and finished, while a file the person chose has to be chosen again. A file still being written is removed 24 hours after its last write. One file is at most 25 MiB, and one widget keeps at most 128 MiB of your 1 GiB. That share counts the files the widget made and what it attached from them, but not the files you chose for it. A widget can discard only a file it made. Every answer is a ref, bytes, or a refusal whose code says what was wrong. A file's name goes out as both filename and filename* (RFC 6266), so a name with accents downloads as it was written.
Deleting the conversation releases its widget files and grants, including finished files never attached. Shared bytes stay; physical cleanup waits for the database commit.
The widget routes start with /conversations/{id}/widgets/{instanceId}/artifacts, written … below.
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /artifacts/{artifactId} | – | The artifact, with its artifactRef. Someone else's answers 404 ARTIFACT_NOT_FOUND. |
| GET | /artifacts/{artifactId}/content | – | The bytes of a finished file. 409 ARTIFACT_NOT_FINALIZED while it is still being written. |
| POST | /artifacts/{artifactId}/export | { suggestedName? } | Save As: the bytes as a download under a file name, never a path. The name keeps the extension of the file's type. The WebSocket relay, MCP and clarkcant api refuse it with 403 PERSON_ONLY. |
| POST | … | { mimeType, name? } | A new file this widget may write. 201 { artifactRef }. |
| POST | …/pick | { name, mimeType, contentBase64, accept? } | A file the person chose, handed to this widget. Refused with 403 PERSON_ONLY on the same relays. |
| GET | …/{artifactId}/content?offset=&length= | – | { artifactRef, offset, eof, contentBase64 }, at most 262,144 bytes per read. |
| POST | …/{artifactId}/chunks | { offset, contentBase64 } | At most 262,144 bytes, starting where the file ends. 409 ARTIFACT_INSTANCE_QUOTA_EXCEEDED once the widget holds 128 MiB. |
| POST | …/{artifactId}/finalize | – | Fixes the bytes after checking them against the declared type. |
| POST | …/{artifactId}/attach | { name? } | 201 { artifactRef, attachmentRef }. The file waits in the composer; the person sends it with their next message. name proposes the attachment's file name. The node sanitizes it, and the artifact's own name when name is absent: it removes path parts and unsafe or invisible characters, limits the length, and gives the file the extension of its type. When nothing usable is left, it uses a default such as untitled.png. The first attach decides the name: attaching the same file again returns the same attachment. |
| DELETE | …/{artifactId} | – | Discards a file this widget made: { discarded: true, artifactId }. Its bytes go once nothing else uses them. Another widget's file, or one the person chose, answers 403 ARTIFACT_NOT_CREATOR. |
These routes are in /openapi.json. Choosing a file and Save As are the person's own actions, so a widget asks the host for them and cannot do them itself. Replacing the file a widget opened is offered only in the desktop app, only with a file of the same type, and the button names both files. In a browser Save As is a download, so the app says “Download started” rather than that the file was saved.
Widget file writes from a machine surface
On a machine surface (MCP, the WebSocket relay, clarkcant api), a widget's five file writes (create, chunks, finalize, attach, discard) go through the person's execution policy, and every one is audited without its bytes. Each is a local-write effect. The exception is discarding a file that is not an unfinished one the same asker started: that may delete the person's only copy, so it is destructive and is asked about in Guarded and in Autonomous mode. The policy either runs the write, refuses it with 403 POLICY_REFUSED, or asks. When the person opted to be asked about machine-surface turns (machineTurns: "ask"), each write is decided as a turn that surface asked for: a destructive discard is then asked about even over a rule that runs destructive effects, and the card says who asked.
When it asks, a host-owned approval card appears in the conversation, in the person's language, and the caller gets 202 { outcome: "approval-required", approvalRequired: { approvalId } }. Only the person can decide the card. Asking again for the same thing returns the card already waiting. Past 8 waiting cards from one surface in one conversation, the answer is 429 APPROVALS_PENDING. That count is kept in memory, so it resets on restart. A card covers one file, never one chunk, and never carries bytes.
Approving a create, or write access to an existing working file, gives a 15-minute write right on that one file: its chunks and finalize run without another card. The right ends on finalize, discard or expiry. Over the WebSocket relay, only the one connection that asked holds it. MCP calls and clarkcant api runs have no per-client identity, so there any client of that surface holds it, not just the one that asked. The card and its receipt say which.
Long-running package jobs
A package capability whose work takes longer than one press declares "execution": { "kind": "job", "version": 1 } in its tools facet. A press on it does not wait for the service: api.actions.invoke resolves with a JobRef, an opaque job_… id, and your machine runs the call in the background for up to 30 minutes. The widget keeps the JobRef in its state, so it can follow the same job after it is opened again.
const jobId = await api.actions.invoke("binding_notes_export", { steps: 12, stepMs: 1500 }, invocationId);
await api.state.update((state) => ({ ...state, exportJob: jobId }), { exportJob: jobId });
const stop = api.jobs.subscribe(jobId, (job) => render(job));
api.jobs.get(ref) reads the job's status (queued, running, waiting, completed, failed or cancelled), progress, output, error and result files. api.jobs.subscribe(ref, handler) checks once a second, passes on only changes, and stops by itself when the job ends. api.jobs.cancel(ref) stops it. Progress is only what the service itself reported, and result files arrive as artifactRefs, never paths.
Listing a widget's jobs (jobs.list@1). api.jobs.list() returns the jobs this widget's own buttons started, newest first and at most 20, including ones Clark or voice started through the same button, so a widget can show work it did not start from a click. Listing came after jobs@1 and is offered as its own extension, jobs.list@1, beside it; api.jobs.canList() says whether the app offered it, and api.jobs.list() refuses without sending anything when it did not. A widget that lists checks first: without a list, it shows the jobs started while it is open and says that earlier ones are not shown. A listed job has the same fields as one read with api.jobs.get.
A failed job may carry the service's own words. When the service reports an error, its job's error quotes it inside your node's own sentence: The package service reported an error: “…”. Its effect may have happened; review before retrying. The quoted part is the service's, and may be its provider's: control and invisible formatting characters (bidi overrides included) are removed and it is cut at 400 characters, but nothing else vouches for it. Show it as what the service said, inside a sentence of your own. A provider key the provider echoes back is already [redacted] there (how a service reaches its provider).
A JobRef is a pointer, not a permission. Your machine answers only the widget whose own button started the job, in the same conversation, under the same package version and capability; anything else, including a ref copied into another widget, is 404 JOB_NOT_FOUND, the same as a job that never existed. If your execution mode asks before the capability runs, the press shows the app's approval card first, and the job starts only after you approve it there.
A job runs for at most 30 minutes; past that it ends as failed. While it runs it is listed with your node's running work (GET /work), and stopping it there (POST /work/{id}/cancel), emergency Stop and shutting the node down cancel it. The conversation's Stop ends the reply, not a job that is already running, the same as other background work. Because the service may have finished its work before it heard the cancel, the job says it “may already have completed its effect” rather than claiming nothing happened. A job still running when your node restarts is marked failed and is never run again by itself. When a job ends, the conversation and the inbox say so, naming up to three result files. A conversation with a running job cannot be deleted until the job ends or is cancelled.
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | /conversations/{id}/widgets/{instanceId}/jobs | – | { jobs: [...] }, newest first and at most 20: the jobs this widget's own buttons started. Each entry has the same fields as the single-job route's job and passes the same owner check. An instance outside the conversation is 404 INSTANCE_UNKNOWN. In a widget this is api.jobs.list(). |
| GET | /conversations/{id}/widgets/{instanceId}/jobs/{jobId} | – | { job: { jobId, status, progress?, resultRefs, output?, error?, createdAt, startedAt?, endedAt? } }. |
| POST | /conversations/{id}/widgets/{instanceId}/jobs/{jobId} | – | Cancels the job: 202 { accepted, jobId }. A job that already ended answers 409 JOB_NOT_RUNNING and stays readable. |
These routes are in /openapi.json. A node that cannot run jobs answers 503 JOB_UNAVAILABLE. POST /stop counts the jobs it cancelled in stopped.jobs. By design, these routes, the list included, are open to machine clients such as an MCP or relay client, not only to your own screens: a machine client can list an instance's jobs, their outputs and their artifactRefs without knowing a JobRef first. It learns nothing it could not read one job at a time, and it cannot read a file's bytes through these routes.
A press that reads what a widget shows
An isolated widget describes what it shows with api.semantic.publish(summary, selectedIds, values?). The app sends the last publish of a burst after 250 ms, and sends publishes one at a time, in order. An agent binding can read that description when it is pressed, through its contextRefs (selection, widget), so a press made right after a publish must not run against the previous description.
Before such a press runs, the app sends any publish still settling and waits until your machine holds the description published before the press, or a newer one. Publishes made after the press are sent as usual but not waited for, so a widget that keeps publishing cannot hold a press back. Any other press waits only for a publish still settling, or for one your machine refused last.
The wait is bounded: each send is given up after 5 seconds and the whole wait after 8. A description your machine refused is sent once more. If that fails too, or the wait runs out, api.actions.invoke rejects with the app's refusal (“What the widget shows did not reach Clark in time, so this action did not run…”) and the press does not run. Nothing in the widget changes, and the person can press again. Show that refusal where the press was made. The reference text editor does this for its rewrite button.
Actions Clark asks a widget to perform
An isolated widget can offer Clark actions it performs itself, such as formatting the selected cells: it declares them in offeredActions and handles each one with api.actions.offer(name, handler) (actions.perform@1). Clark can then ask a widget on screen to perform one of those actions, through your execution policy, and only a screen that shows the widget performs it. Clark never writes into the widget and never approves the action itself.
When your execution policy asks first, Clark puts an approval card in the conversation, and nothing is sent to the widget until you approve. The card shows the whole input the widget will receive; an action whose input is too long to show in full (more than 1,200 characters) is refused rather than shown in part. If Clark asks for the same action with the same input while its card is still waiting, you get that same card, not a second one, and a conversation holds at most 8 waiting action cards, however many surfaces asked. Approving asks the widget on the screen you approve on; approving from the inbox, or from a page that does not show the widget, is recorded as “approved, not performed”, with nothing sent. Performing an offered action is a local write, so the opt-in to be asked about program turns does not by itself ask about it; a rule or mode that asks does. Who asked is written on the approval card and in the audit, also after you approve.
For widget authors. To refuse an offered action, throw api.actions.refuse("CODE", "why") before changing anything. Any other error, including the SDK's own rejections, means the widget may have changed something: Clark says whether the action took effect is unknown and does not retry it.
What a package runs with
A package's services run in a container on your machine. The package asks for the size of that container by naming a resource profile in its manifest, never with numbers:
"resources": { "version": 1, "profile": "interactive-heavy" }
Your node owns the table and decides what it grants. A package that names no profile runs as interactive-light, the envelope every service ran in before profiles existed, value for value, so an existing package runs exactly as it did.
| Profile | Memory / CPUs / processes / /tmp | Per call | Per job | Jobs at once | Out of view |
|---|---|---|---|---|---|
interactive-light (default) | 256 MiB / 1 / 128 / 16 MiB | 60 s | 30 min | 4 | Unmounted |
interactive-heavy | 1 GiB / 2 / 256 / 64 MiB | 120 s | 30 min | 2 | Unmounted |
media-workstation | 4 GiB / 4 / 512 / 512 MiB | 300 s | 2 h | 1 | May keep playing |
background-compute | 2 GiB / 2 / 256 / 256 MiB | 60 s | 4 h | 2 | Unmounted |
Every profile runs with no network (--network none) and a noexec /tmp. The profile sets the container's memory, CPUs, process limit and /tmp size, the call and job deadlines, and how many jobs run at once. Services and jobs keep running whether or not their widget is on screen. The largest file a service returns stays the attachment maximum, so it can always be attached to a conversation.
Your node decides in this order:
- A GPU is never granted: the node does not pass one through to a container.
interactive-lightis always granted.- A refusal from your execution policy wins.
- A profile is not granted when it needs more CPUs than the container engine reports, or more than half its memory. Under Docker Desktop that is the capacity of its Linux VM.
A profile that cannot be granted is never swapped for a smaller one. The package is degraded instead: its services do not start, and each capability shows the reason. Settings → Extensions shows the profile beside each installed package: its bounds when granted, or “Asked for …, not granted: …” when not.
Rootless Podman without delegated cgroup v2 controllers accepts memory and CPU limits and does not apply them. Your node still grants the profile there, and package details say “the container engine does not enforce memory and CPU limits here” rather than claiming the limits.
A widget unmounts when it scrolls out of view. Only a widget whose package was granted media-workstation gets a “Keep playing when scrolled away” toggle in the app's own frame. While it is on, the widget stays mounted and the app says “title is still running out of view”. It is off for every new mount, the widget cannot turn it on itself, and Stop still ends it.
How a service reaches its provider
A service's container stays on --network none, and the service never holds a provider key. Its package's tools facet declares the keys it needs and the origins it may reach, each with a purpose:
"egress": {
"version": 1,
"secrets": [{ "name": "LOOKUP_API_KEY", "purpose": "Signs the lookups in with the provider." }],
"origins": [{
"origin": "https://api.example.com",
"purpose": "Looks up the words you ask about.",
"credential": { "secret": "LOOKUP_API_KEY", "header": "authorization", "scheme": "bearer" }
}]
}
Your node is the MCP client of each package service, over stdio. For a service that declares egress, the node's initialize offers capabilities.experimental["clarkcant/egress"] with version: 1. The service then sends the node the request clarkcant/egress.fetch with { version: 1, url, method?, headers?, body? } on the same connection, and the node makes the HTTP request itself:
- Declared origins only. The origin must match a declared one exactly, and a URL with credentials in it is refused. No redirect is followed, so a key never travels to another origin.
- No loopback or private origins.
localhost, loopback, private and link-local addresses (IPv4, IPv6 and IPv4-mapped forms) are refused even when declared, unless you start the node withCC_EGRESS_ALLOW_PRIVATE_NETWORK=1. It is off by default, and no manifest field turns it on. The check reads the URL's host; it does not check what a public name resolves to. - Only while a call is running. A request is answered only while a call from your node to that service is in flight. Requests stop when the last call ends, is cancelled, or the service is stopped.
- Read-only calls can only read. While every call in flight was decided as
readorlocal-write, a request may only useGETorHEAD. Any other method needs a call decided asexternal-write,destructive,financialorcommunication, which your execution policy's risk check asks about. - Rate limited. Each running service gets a burst of 30 requests, refilled at 10 a second.
- The key is added by the node. The node adds the declared header from the key you stored for the consumer
package:<id>; a header of that name set by the service is dropped. Cookie, proxy, forwarding and framing headers are stripped. A request sends at most 1 MiB, receives at most 2 MiB and takes at most 30 s. - Secret redaction. The key is replaced with
[redacted]in the headers and body that come back, as sent and in its JSON-escaped, URL-encoded, base64 and base64url forms. This is a best-effort guard against a provider that echoes the key, not a guarantee: a key returned in another form, such as split, hashed or encrypted, reaches the service.
A refusal is a JSON-RPC error from -32010 to -32018: origin not declared, no call in flight, key unavailable, too large, provider unreachable, stopped, method not allowed by the calls in flight, too many requests, and an origin this node does not reach. -32019 is a request refused because a call in flight holds a file you picked (files a service reads). Each request is audited as egress with the package, method, origin, key name, status and outcome, never a path, a body or a value. This method is between your node and its own services; it is not on POST /mcp.
Until the declared key is stored, the package's capabilities read as not signed in (authenticated: false) with the reason, for example “the secret LOOKUP_API_KEY has not been provided on this node”. A press, the agent and voice are refused with 409 CAPABILITY_NOT_AUTHENTICATED. This is the “needs auth” state of a service; the widget lifecycle state needs_auth is not set for it. Storing the key for package:<id> with POST /credentials signs the service in without a restart, and removing the key signs it out. A key stored for a command: consumer is not used for egress, so store a separate one for the package.
A proxied network for services that need raw sockets is not built. Giving a container a network would weaken an isolation default.
Files a service reads
A service can work on a file a widget holds without ever getting a path or a handle to it. A capability names the argument fields that carry artifact ids in its tools facet:
{ "tool": "render_audio", "ref": "com.example.media.render@1", "effectCategory": "read",
"execution": { "kind": "job", "version": 1 },
"inputArtifacts": { "version": 1, "fields": ["source"] } }
A call that names a file in one of those fields can only come from the button of the widget instance that holds the file. The same call from Clark, voice, MCP or the CLI is refused with 403 ARTIFACT_INPUT_REFUSED, because none of them holds the widget's grant. Before the call is sent, your node checks each named file:
- the pressing widget instance holds a grant on it, and it is finished, not still being written (
403 ARTIFACT_INPUT_REFUSED); - it belongs to the conversation the press happened in, and that conversation still holds the widget (
403 ARTIFACT_INPUT_REFUSED); - the node can name the resource profile the package was granted; a package with none is refused rather than run with no cap (
403 ARTIFACT_INPUT_REFUSED); - it fits that profile's input cap (
413 ARTIFACT_INPUT_TOO_LARGE).
A refused call sends nothing to the service. When your execution policy asks before such a call, the approval card names the widget and button the press came from, and what you approve covers them. Approving runs the call as that press: the node checks again that the conversation still holds the widget and that the button still invokes this capability, then makes every check above. A card whose widget is gone is refused with APPROVAL_STALE.
The caps come from the granted profile, never from the manifest. A manifest names fields, not sizes, so it cannot raise either cap:
| Profile | Input file | Media length |
|---|---|---|
interactive-light | 8 MiB | 2 min |
interactive-heavy | 16 MiB | 10 min |
media-workstation | 25 MiB | 2 h |
background-compute | 25 MiB | 1 h |
Your node offers file reads in the MCP initialize as capabilities.experimental["clarkcant/artifacts"]: { version: 1, methods: ["clarkcant/artifacts.read"], chunkBytes: 262144, maxInputBytes, maxMediaSeconds, maxResultBytes }. During the call, and only then, the service sends clarkcant/artifacts.read with { version: 1, artifactId, offset, length } and gets back { artifactId, offset, bytes, eof, sizeBytes, mimeType }, with the bytes in base64:
- Bounded reads. One read returns at most 256 KiB; a longer range is refused with
-32602. The node checks the grant again on every read. - Only the named files, only during the call. An id the call did not name in a declared field is refused with
-32020, and so is a read whose answer arrives after the call has ended; its bytes are not handed over. - Other refusals. A revoked grant, a file that is gone, or a call that has spent its read budget is
-32021. The budget is four passes over the call's files, in at most four reads per 256 KiB piece, plus 16 more reads for headers and seeks. - Media length is the service's to enforce. Only the service can read a clip's length, so it should refuse one longer than
maxMediaSecondsbefore it does any work.maxResultBytesis the largest file one result may carry, about 2.95 MiB. A returned file becomes an artifact only when the job completes, so a cancelled or failed job leaves no file presented as finished. - No egress while a file is held. While a call that holds a file is in flight, the service's
clarkcant/egress.fetchis refused with-32019, unless that call was decided asexternal-write,communication,destructiveorfinancial, the effects your execution policy's risk check asks about. AGETcan carry bytes in its URL, so areadorlocal-writecall that holds a file gets no egress at all until it ends. A capability that both reads your file and sends it to a provider declaresexternal-writeor higher, so your policy decides that send.
The reference media render tool is built on this: its widget picks a WAV clip, and its service streams the clip a piece at a time under background-compute.
Connecting an account
A service that works on your account at a provider, such as your tasks or calendar, declares one connection on its tools facet. Your node does everything that touches the account's credential: the service sees the provider's answers and the widget sees a status. Neither ever holds an access token, a refresh token or an authorization code, and neither does the page or the service's container.
"connection": {
"version": 1,
"provider": "fake.tasks",
"displayName": "Fake Tasks (test fixture)",
"flow": "oauth-pkce",
"authorization": {
"authorizationEndpoint": "http://127.0.0.1:8880/oauth/authorize",
"tokenEndpoint": "http://127.0.0.1:8880/oauth/token",
"revocationEndpoint": "http://127.0.0.1:8880/oauth/revoke",
"clientId": "connected-app-dev"
},
"scopes": [
{ "scope": "tasks.read", "purpose": "Lists your tasks." },
{ "scope": "tasks.write", "purpose": "Renames a task when you ask." }
],
"endpoints": ["http://127.0.0.1:8880"],
"probe": { "url": "http://127.0.0.1:8880/api/me" }
}
The only flow is authorization code with PKCE, so a package never carries a client secret, and the client id is public. Every URL must be HTTPS unless it is a loopback address. The probe must be on a declared endpoint. A package declares at most one connection, and an endpoint cannot also be an egress origin, so each origin has one credential. Each capability names the scopes it needs in requiredScopes, and each must be a scope the connection asks for.
- Connect. You press Connect on the package in Settings → Extensions. This is the app's own UI, never the widget's.
POST /packages/{id}/connectionmakes a PKCE pair (S256) and a single-usestate, kept in memory for ten minutes, and answers the provider's authorization URL, which the app opens in the system browser. Connecting is person-only: through MCP or the relay it is refused with403 PERSON_ONLY, so an AI client cannot start a connection for itself. It is also refused with409 CONNECT_ON_THIS_MACHINEunless the request reached your node over loopback, because the provider sends the browser back to that machine. - Come back. The provider redirects to
http://127.0.0.1:<port>/connections/callback/<package id>. Each package has its own callback path, and your node checks that the state was issued for the package the path names before the code goes anywhere, so a code sent back for one package is never exchanged for another's (the OAuth mix-up attack). It exchanges the code at the declared token endpoint, compares the granted scopes with the declared ones and calls the probe. Only then does it keep the connection, with the tokens in their own table. The page the browser lands on never repeats the code or the state and sends no referrer, and a replayed or forged callback is refused. - Sign. The service still runs with no network. When it asks for a URL on one of the connection's endpoints with
clarkcant/egress.fetch, your node addsauthorization: Bearer …itself, only while one of the service's calls runs, drops anyauthorizationheader the service set, and redacts the token from the answer it hands back. With no usable token the request is refused with-32012. Each request is audited asegresswith the connection's provider, never the token. - Refresh. A token about to lapse is refreshed first, and a renewal keeps the scopes granted at consent unless the provider says otherwise. A provider's
401to the token that was actually sent gets one refresh. Only a400or401from the token endpoint then revokes the connection; a408,429or5xxleaves it as it is. A request is never retried. - Readiness. The status is
not-connected,connected,partial,expiredorrevoked, with the requested, granted and missing scopes and a reason. A capability is not ready while the connection is missing, expired or revoked, or did not grant one of itsrequiredScopes, and the reason says which, for example “the Fake Tasks (test fixture) account did not grant tasks.write; reconnect it in Settings and allow it”. A widget reads it fromactions.availability(); a press, the agent and voice are refused withCAPABILITY_NOT_AUTHENTICATEDand the same reason. A capability whose scopes were granted keeps working on a partial connection. - Revoke and reconnect. Revoke calls the provider's revocation endpoint when one is declared, deletes the tokens, and the status reads
revokedbefore the request answers, even when a renewal was in flight. Reconnect runs Connect again. Uninstalling the package, from Settings or by asking Clark, forgets the connection the same way, and an authorization still waiting for its callback can no longer finish. - A new version. Your node keeps a fingerprint of where the declaration let the account's tokens go: the provider, the client id, the authorization, token and revocation endpoints, the endpoints and the probe. If an update or a rollback changes any of them, the connection reads
revoked, its tokens are deleted without being sent to the new addresses, and you connect again under the new declaration.
In Settings. Settings → Extensions shows each package's account: its state, the granted scopes and the reason it is not fully usable. Connect appears while it is not connected and Reconnect otherwise, and Revoke while it is connected or partly connected. While you sign in at the provider, Settings says it is waiting and reads the status back every 1.5 s for up to five minutes.
One capability, one audit log. A connection changes nothing about how a capability runs. The widget's button, Clark's invoke_capability and a spoken command still reach the same capability, your execution policy, the app's own approval card and the same audit log. A write whose answer never came back before its deadline is recorded as unknown and not retried.
| Method | Path | Answers | Refusals |
|---|---|---|---|
| GET | /packages/{id}/connection | 200 { connection }: the status above, never a token. | 404 NOT_INSTALLED, 404 NO_CONNECTION, 409 MANIFEST_UNREADABLE, 503 CONNECTIONS_UNAVAILABLE |
| POST | /packages/{id}/connection | 200 { authorizationUrl }. Person-only. | Those above, plus 403 PERSON_ONLY, 409 CONNECT_ON_THIS_MACHINE, and 409 ENDPOINT_REFUSED for a loopback or private provider address on a node started without CC_EGRESS_ALLOW_PRIVATE_NETWORK=1 |
| POST | /packages/{id}/connection/revoke | 200 { connection }, already revoked. | As for GET |
| GET | /connections/callback/{packageId} | Public. 200 “Connected to …”, or 400 “Not connected” with what failed and that nothing was connected. | 503 when the node does not connect accounts |
GET /packages also carries each installed package's connection status. Revoking is not person-only: any surface holding your node's token may revoke, on purpose, because revoking only narrows access.
The reference connected app proves this against its fake connector, a test and development fixture, not a live provider. No live provider ships yet (digitopvn/clarkcant#333). The desktop app opens only HTTPS addresses in the system browser, so a provider on a loopback http address, such as the fake connector, is connected from the browser client. Only the authorization-code flow with PKCE is built; several accounts for one package, and a connection shared between packages, are not. Installing a package again after uninstalling it currently leaves its service inactive, while restoring it works (digitopvn/clarkcant#400).
Browser tokens for a widget (tokens@1)
Some vendor SDKs only work with a token in the browser. A package's UI facet may declare the providers its widget needs one from, at most 8 providers with 16 scopes each:
"browserTokens": {
"version": 1,
"providers": [{ "provider": "example.maps", "scopes": ["tiles:read"], "purpose": "Draws the map tiles." }]
}
Only a widget whose package declared browser tokens is offered tokens@1 in init.extensions:
if (api.tokens.available()) {
const token = await api.tokens.request({ provider: "example.maps", scopes: ["tiles:read"], ttlSeconds: 300 });
sdk.setAccessToken(token.value); // { provider, value, scopes, expiresAt }
}
A token is bound to one widget instance and one session. Each mount of the widget is its own session, a random id the app keeps, and the token is revoked when that mount goes, when the package is uninstalled, rolled back or updated to new code, and when the node stops, where the provider supports revoking. A token is issued only when the provider and every scope are declared, your node has an adapter for that provider that mints a scoped token for those scopes, and the lifetime is 30–3600 s and within the provider's own maximum (900 s when none is asked). A request is refused, never narrowed. A session holds at most 8 tokens; a ninth withdraws the oldest.
Your node keeps only the provider's token id, and audits the provider, instance and outcome as browser-token, never the value. The SDK and the app both refuse, with TOKEN_NOT_ALLOWED, a state update, semantic publish, action, file write or external link that carries an issued token as it was issued. This guard is best effort: the widget's code holds the value, so what bounds a leak is that the token is scoped, short-lived and revoked when the widget goes.
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /conversations/{id}/widgets/{instanceId}/browser-tokens | { session, request: { provider, scopes, ttlSeconds? } } | { token: { provider, token, scopes, expiresAt } }. Person-only: the app asks on behalf of the widget it mounted. |
| DELETE | /conversations/{id}/widgets/{instanceId}/browser-tokens/{session} | – | { ended: true, revoked }. A later request for that session is 409 TOKEN_SESSION_ENDED. |
These routes are in /openapi.json. Refusals: 403 TOKEN_PROVIDER_NOT_DECLARED or TOKEN_SCOPE_NOT_DECLARED, 409 TOKEN_PACKAGE_NOT_ACTIVE or TOKEN_SESSION_ENDED, 422 TOKEN_PROVIDER_UNSCOPED, TOKEN_SCOPE_NOT_SUPPORTED or TOKEN_TTL_TOO_LONG, 502 TOKEN_ISSUE_FAILED and 503 TOKEN_PROVIDER_UNAVAILABLE. ClarkCant ships no provider adapter yet, so today every request is 503 TOKEN_PROVIDER_UNAVAILABLE.
What a package reaches, shown before install
A directory entry states the package's reach in declaredReach, { origins, secrets, browserTokens, connections }; a listing without it says the package reaches nothing. Before anything is granted, the directory card in the conversation, the install question in the inbox, and package details in Settings → Extensions list each origin with its purpose, each key by name with its purpose (never a value), each browser-token provider with its scopes and purpose, and each account connection with its provider, scopes and endpoints.
A node reads a directory entry with fields it does not know by leaving those fields out rather than refusing the entry. The directory card then says how many details this version of Clark could not read, and names those whose names are plain identifiers; a newer Clark shows them.
An artifact whose manifest declares a different reach than its listing showed is refused with 409 DECLARED_REACH_MISMATCH before anything is recorded, so what you agreed to is what you get. The resource profile is shown in package details once the package is installed.
What an update shows. A package update notice, and the install question an update raises when the execution mode asks first, compare the new version's listing with the installed version's manifest and carry reachChange: each origin, key, browser-token scope, account scope and account endpoint the new version adds or drops; each origin a key is now or no longer sent to; a GPU request, which this node never grants; each bounded resource limit that changes, with both values; and the offscreen behaviour when it changes. The verdict is wider when anything is added, any limit goes up or the offscreen behaviour moves from suspend to authorized-playback, narrower when something is only dropped or lowered, and unchanged otherwise. It is absent when the package is not installed, and { verdict: "unknown" }, shown as a note that it could not be compared with the installed version, when the installed manifest or the new listing cannot be read. Each list holds at most 32 items and counts the rest. It informs the decision and decides nothing: an update is still decided by the execution policy like any install.
Paired nodes
Two ClarkCant nodes can be paired, for example your laptop and a server. A paired node can tell the other what happened, put a notice in the other owner's inbox, and hand it a task, each only as far as the owner of the receiving node allowed.
These routes take the same token but are not in /openapi.json yet and may change. Confirming a peer and writing a grant are the owner's decisions: the WebSocket relay, MCP and clarkcant api refuse them with 403 PERSON_ONLY. Today nodes talk plain HTTP to each other's gateway, so a node must be reachable over HTTP to pair, and a person checks the other node's key by comparing fingerprints.
Pair two nodes
One node invites, the other claims the invitation, and a person confirms on both nodes. Until both have confirmed, the peer is pending: nothing is sent to it and its messages are refused.
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /peers/invites | { endpoint } | On the inviting node, with the address the other node should reach it at. 201 { invite } with inviteId, issuerNodeId, endpoint, fingerprint and expiresAt. Single use, valid for 10 minutes. |
| POST | /peers/claim | { inviteId, node } | The claiming node calls this on the inviting node, without that node's token. node is the claimant's { nodeId, label, endpoint, publicKey, fingerprint, tokenHash }. 200 { issuer, tokenHash }, and the claimant is recorded as pending. 404 INVITE_UNKNOWN; 409 INVITE_EXPIRED or INVITE_ALREADY_CLAIMED; 400 FINGERPRINT_MISMATCH when the fingerprint does not name the key offered. |
| POST | /peers/record | { node } | On the claiming node: the issuer from the claim's answer, with the endpoint you reached it at and the answer's tokenHash. 201 { nodeId, trustedAt }, still pending (trustedAt is null). |
| GET | /peers | – | Each peer's nodeId, endpoint, fingerprint, pairedAt, trustedAt and revokedAt. |
| POST | /peers/{nodeId}/confirm | – | A person's decision, made after comparing fingerprints: the invite carries the inviting node's, and GET /node shows each machine's own. 200 { nodeId, trusted: true }; 404 PEER_UNKNOWN. |
| POST | /peers/{nodeId}/revoke | – | Ends the pairing on this node: it takes no more messages from that peer and sends it none. 200 { nodeId, revoked: true }; 404 PEER_UNKNOWN. |
No token crosses the wire while pairing. Each node presents its peer a token derived from its own localToken (the base64url HMAC-SHA256 of peer:<peerNodeId>, keyed with localToken), and tokenHash is the hex SHA-256 of that token. Only the hash is stored.
curl -s -X POST "$CLARKCANT_URL/peers/invites" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "endpoint": "http://laptop.local:8765" }'
# 201 → { "invite": { "inviteId": "…", "fingerprint": "…", "expiresAt": "…", … } }
# Once the other node has claimed it and you have compared fingerprints:
curl -s -X POST "$CLARKCANT_URL/peers/<nodeId>/confirm" \
-H "Authorization: Bearer $CLARKCANT_TOKEN"
Write a grant
POST /grants takes one grant: { grantId, ownerPrincipalId, senderNodeId, receiverNodeId, capabilityRefs, resources, allowedDataClasses, expiresAt, budget?, maxDelegationDepth, allowedEffectCategories? }. It names this node as the sender, this node's owner (ownerPrincipalId in identity.json) as the owner, and a confirmed peer as the receiver. The grant is stored and queued to the peer in one write, and from then on the peer accepts work this node hands it under that grant, until the grant expires or is withdrawn. The peer still runs only what its own owner allowed: it intersects the grant with its own allowance, so a grant can narrow what runs there but never widen it.
201 { grantId, receiverNodeId, allowedDataClasses }.400 INVALID_SCHEMA, or400 GRANT_NOT_OURSwhen the sender is not this node;403 NOT_THE_OWNER;404 PEER_UNKNOWNwhen the receiver is not paired and confirmed.
You rarely write one by hand. Setting up an automation in conversation that runs on a paired node writes the grant for exactly its folders, repositories and effects, and pausing or removing the automation withdraws that grant on both nodes.
Before you hand work over, you can see what the other node would run for you. Ask Clark to list your paired nodes (list_peers): for each one it shows whether that node's owner allows your node anything, and which of those capabilities it can run right now. That is all your node learns: the folders and anything else the allowance covers stay on that node. When you set up an automation to run there, Clark warns you but doesn't refuse if that node hasn't allowed yours, doesn't allow what the task needs, or can't run it yet. A node that couldn't be asked, because it runs an older ClarkCant or didn't answer within 5 seconds, is reported as unknown. The check made when the task arrives there still decides.
A task that arrives before that node can run what it needs, for example while a pack is still loading, waits there instead of being refused, and both owners are told which capability it is waiting for. There's no time limit: it starts by itself once that capability is ready, and stopping it where you set it up ends it at once. On a pairing made before this, until each node has delivered something to the other, such a task is still refused at once, and the warning says so.
A task handed to a paired node can bring back the files its run wrote, but only when both owners allow it. On your side, give the automation a byte budget with maxArtifactBytes on create_automation, up to 16 MiB. On the other side, that node's owner gives your node one with maxArtifactBytes on allow_peer_tasks. Without both, the files stay on the other node, and the result says so. Files come back within the smaller budget, with at most 8 files and 4 MiB per file. Only files written with the project-file tool inside the task's folders are offered, never HTML, SVG or scripts. Your node checks each one against your own grant for that task, fetches it once, and checks its digest before keeping it. Returned files appear with the result in the same conversation, and any file left out or refused is listed with the reason.
Tell a paired node what happened
POST /peers/{nodeId}/signals { id, topic, payload?, subject?, occurredAt? }, sent to your own node with its own token. id is your name for the event. The signal is queued before it is sent, so it is not lost to a peer that is offline right now, and the peer records it once however often it is retried.
202 { messageId, queued: true }.404 PEER_UNKNOWNwhen the peer is not paired and confirmed;400otherwise.
The peer records it as peer.<topic> from the node the authenticated channel names, so it cannot pass for a GitHub delivery, a signed webhook or a timer. A signal is a fact, not a command: it needs no grant and asks for nothing, and only the standing requests the peer's owner set up there decide what, if anything, it starts.
curl -s -X POST "$CLARKCANT_URL/peers/<nodeId>/signals" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": "build-812", "topic": "build.finished", "payload": { "status": "failed" } }'
# 202 → { "messageId": "…", "queued": true }
Put a notice in the other owner's inbox
POST /peers/{nodeId}/notices { id, title, body?, category?, severity? }, sent to your own node with its own token. id is your name for the event. category is result, update, message (the default) or alert; severity is info (the default), success, warning or error. Nothing on a node sends notices by itself yet, so this route is how a script, the CLI, an MCP client or an automation tells a paired Clark.
202 { messageId, queued: true }: queued, not yet taken by the peer.404 PEER_UNKNOWNwhen the peer is not paired and confirmed.409 NOTICES_UNSUPPORTEDwhen the peer has not said it takes notices. A node learns that from the peer's answer to anything it delivers, so send that peer something first, a signal for example, and try again. If the peer runs a ClarkCant from before notices, update it there.400for anything else.
A notice is words and nothing more: an id up to 160 characters, a title up to 120 and a body up to 500, with no actions, links or subject. The node that shows it decides what can be done with it, and treats its text as data. It is recorded as your node's, once per id, so a retry or a replay shows once. One peer keeps at most 20 notices waiting there, its oldest going first, without pushing out that node's own or another peer's.
The receiving node takes a notice only on its own owner's decision to work with your node: a live grant that owner wrote to your node, or their allowance for your node to run work there. A grant your node wrote does not count, and neither does pairing alone. It also takes at most 30 a minute from one peer. A refusal is final, not a failed delivery: your node does not send that notice again, and tells you in your own inbox, once per peer and reason, what did not arrive, why, and what would change it.
curl -s -X POST "$CLARKCANT_URL/peers/<nodeId>/notices" \
-H "Authorization: Bearer $CLARKCANT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "id": "backup-2026-09-30", "title": "Nightly backup failed", "severity": "error" }'
# 202 → { "messageId": "…", "queued": true }
In the inbox
- Notices can come from a paired node. They are marked as that node's, and carry no actions from it.
- When delivery to a paired node has been failing for more than 10 minutes, you get one notice for that outage, not one per message; if what is wrong changes, a new notice replaces it. It says what is wrong: the device has not answered since a given time, it answers but refuses, ClarkCant there failed to handle the messages, or sending stopped. It says whether what is owed stays queued and is still retried. The notice clears itself as soon as that node acknowledges anything again, or when you revoke the pairing.
- When a node gives up on a message to a paired node, what it sends after that is still delivered, in order. Both owners get one notice. It says what failed, that later messages were kept, what happens to the tasks involved and what to do, then lists what was lost and for which task. A lost hand-over or stop settles its task, as failed if it never left your node. A lost result shows the handed-over task as uncertain in its conversation, because nobody can vouch for how it ended there. A lost question, answer or approval is not sent again, and whoever waits for it waits until it expires.
- If the paired node runs a ClarkCant from before this, it cannot move past a lost message. You get one notice that the pairing is stuck and that updating ClarkCant on that device frees it. Once it is updated, it catches up by itself and the notice clears.
Generate a client
The stable surface is described by OpenAPI 3.1, so any OpenAPI 3.1 tool can generate a typed client from it:
curl -s "$CLARKCANT_URL/openapi.json" -o clarkcant-openapi.json
Attachments and images are bytes: send them over HTTP. The same routes are also reachable over WebSocket, MCP and the CLI.