Skip to main content
Version: Next

Cobuild for agents

Cobuild is Honeyframe's building assistant. A person drives it from the chat panel. Another program — a script, a CI job, or an AI agent such as a browser agent, a desktop assistant or a coding agent — should drive it through the HTTP API on this page, or through the Cobuild MCP server that wraps the same API.

Why the API instead of the chat box​

Typing into a chat box is the least reliable way for one agent to use another. Browser agents type in bursts, a newline can send half a prompt, buttons move while a run is streaming, and an approval card can be pressed twice. The API has none of those problems:

  • Every state is explicit. A run is running, paused on an approval, a tool permission or a question, or finished — read from one field, not guessed from a spinner.
  • Approvals are addressed by id. You approve this message or this permission request; a stale or repeated approval is refused with HTTP 409, never applied twice.
  • It is the same Cobuild. The API runs the same planner, the same tools and the same governance as the chat panel. Nothing is skipped: every change Cobuild proposes still waits for a person to approve it.

The API acts as the user whose credential it carries, with exactly that user's permissions. An agent cannot do anything through it that the user could not do in the chat panel.

Authentication​

Credential​

Send a Personal Access Token (PAT) — or a login session token — as a Bearer token:

Authorization: Bearer hf_...

Create a PAT in Honeyframe under your profile → Personal access tokens, or with POST /api/auth/tokens (see Authentication → Personal Access Tokens):

{
"name": "cobuild-agent",
"expires_days": 30,
"scope_org_id": 64,
"scope_project_id": 507,
"read_only": false,
"scopes": ["project.edit"]
}

The response carries the token once ("token": "hf_..."); store it, it is never shown again.

What the token needs:

RequirementWhy
read_only: falseEvery Cobuild route requires the project.edit permission, which is a write permission; a read-only token is refused (403).
scopes empty, or containing project.edit, project.* or *A token's scopes list is a ceiling. Cobuild routes check project.edit, so the list must allow it.
The user holds project.edit on the project, as an editorThe token never grants anything its owner does not have.
The user holds the Cobuild use permissionOrganisations that restrict Cobuild by group grant it under Cobuild → Permissions. An organisation with no Cobuild group rules allows every member. Without it, calls answer 403 cobuild_scope_denied.

scopes: ["project.edit"] is the narrowest token that can drive Cobuild. The tools Cobuild runs on your behalf check their own permissions against the same ceiling, so a token narrowed to project.edit alone may see some tool calls refused. To let Cobuild build anything the user can build, leave scopes empty and pin the token to one project with scope_project_id instead. See the Permissions reference for the permission strings.

Headers​

HeaderRequiredMeaning
Authorization: Bearer <token>alwaysThe PAT or session token.
X-Project-Id: <project id>alwaysThe project Cobuild works in. Without it every Cobuild call answers 400 ("Cobuild requires a project context"). A token pinned with scope_project_id overrides it.
X-Org-Id: <org id>when the user belongs to several organisationsSelects the organisation. A token pinned with scope_org_id overrides it.

The loop​

create session ─► run (background) ─► poll ─┬─► done ─► created-objects ─► (undo)
├─► awaiting approval ─► resume ─────────┐
├─► awaiting permission ─► tool-permission┤
└─► awaiting answer ─► answer ─────────┘
(back to poll)

The base URL below is https://<your-host>. Every request carries the headers above. Request bodies are JSON (Content-Type: application/json).

Create a session​

POST/api/cobuild/sessions🔒 auth

Body: CreateSessionRequest, with first_thread a CreateThreadRequest.

{
"initial_prompt": "Build a dashboard of monthly revenue per branch from fact_revenue",
"first_thread": {"workstream_kind": "dashboard", "title": "test: revenue per branch"}
}

workstream_kind is one of pipeline, dashboard, agent, webapp, investigation. In the response the ids are nested:

{
"session": {"session_id": 412, "project_id": 507, "org_id": 64, "created_by": 9,
"initial_prompt": "Build a dashboard ...", "title": null,
"status": "active", "created_at": "...", "plan_summary": null},
"first_thread": {"thread_id": 988, "session_id": 412, "workstream_kind": "dashboard",
"title": "test: revenue per branch", "status": "...",
"primary_asset_id": null, "plan_state": null, "created_at": "..."}
}

Name test sessions recognisably (test: ...): they appear in the project's Cobuild list for everyone.

Run​

POST/api/cobuild/sessions/{session_id}/run🔒 auth

Body: RunPlannerRequest.

Always send background: true from an agent: the run then survives a dropped connection and the proxy's request timeout.

{"thread_id": 988, "background": true}
  • First run of a fresh thread: omit prompt; the session's initial_prompt is used.
  • Every follow-up: send the new text as prompt (and thread_id). A bare run on a thread that already has messages answers 409.
  • read_only: true offers Cobuild read tools only and refuses every write.
  • model overrides the model for this run, as in the chat panel's model picker.
  • Do not send force: true from an agent. It exists to clear a run that crashed and left the thread stuck on running; it never approves anything, but it does skip the one-run-at-a-time guard.

Response (background):

{"action": "accepted", "status": "running",
"message": "Run accepted — running in the background.",
"message_id": 5120, "session_id": 412, "thread_id": 988, "user_message_id": 5119}

message_id is the assistant turn being written. Without background the call blocks until the turn stops and answers with action = answer, awaiting_approval, awaiting_user_answer or cancelled.

Poll​

GET/api/cobuild/threads/{thread_id}🔒 auth

Returns {"thread": {...}, "messages": [...]}, messages oldest first.

Read the assistant message you are waiting for (the message_id from the run) and its plan_state.termination:

plan_state.terminationMeaningNext step
streamingstill runningpoll again (every 2–5 s)
awaiting_approval with plan_state.tool_permission_requesta tool permission is pendingApprove or deny a tool permission
awaiting_approval without ita plan is pendingApprove or reject a plan
awaiting_questionCobuild asked the user a question (plan_state.question_request)Answer a question
end_turn (or absent)finished; the answer is contentWhat was built
cancelledstopped by an abort—
error, max_iterations, timeoutfailed; content explainsfix the prompt and run again
approval_consumed, question_answeredthis pause was already resolved; a later message carries the continuationread the latest message

thread.status mirrors it at the thread level: running, awaiting_approval, awaiting_tool_permission, awaiting_user_answer, done, failed, cancelled. A run that died without finishing is marked failed the next time the thread is read.

The proposal the user must judge is in plan_state.pending_action.tool_calls (the exact tool names and arguments). Show it to the user; do not summarise it away. tool_calls on a finished message lists what the turn ran ([] = it ran nothing).

Approve or reject a plan​

POST/api/cobuild/messages/{message_id}/resume🔒 auth

Body: ResumePlannerRequest.

{"approved": true, "background": true}
{"approved": false, "note": "Keep the ORDER BY month — and use fact_revenue, not stg_revenue.", "background": true}

A rejection with a note steers Cobuild: it proposes again, taking the note into account. Diff the new proposal against what you want before approving. The response has the run's shape (action: "accepted", a new message_id to poll). options (drop_data, delete_orphan_insights) are the delete confirmation checkboxes; they default to off and only matter when approving a delete.

Approve or deny a tool permission​

POST/api/cobuild/threads/{thread_id}/tool-permission🔒 auth

Body: ToolPermissionDecisionRequest, with request_id from plan_state.tool_permission_request.request_id.

{"request_id": "6f1c...", "decision": "approve", "scope": "once"}
  • decision: approve or deny.
  • scope: once (this call), session (this tool, for the rest of the session — narrowed to the same target when the tool has one), or always. always writes a standing project policy ("don't ask again"); leave that to a person in the Honeyframe UI.
  • This route answers when the resumed turn stops (it has no background field), so allow a long timeout and poll afterwards anyway.

Two field rules:

  • The once-scope quirk. The first once approval is often spent by Cobuild re-proposing the same call: you get a NEW request_id and must approve once more; the second one executes. Budget two rounds.
  • Batched proposals. When one proposal carries several calls (tool_permission_request.batch lists them), once approvals do not converge — each re-proposes. Diff every call in the batch, then approve with scope: "session": it executes immediately and the grant ends with the session.

Answer a question​

POST/api/cobuild/threads/{thread_id}/answer🔒 auth

Body: AnswerQuestionRequest, with exactly one of option_index, other_text or rejected, and request_id from plan_state.question_request.request_id.

{"request_id": "b2e4...", "option_index": 1, "background": true}
{"request_id": "b2e4...", "other_text": "Use fiscal months, April to March", "background": true}
{"request_id": "b2e4...", "rejected": true, "background": true}

other_text is accepted only when the question allows a free answer (question_request.allow_other).

What was built​

GET/api/cobuild/sessions/{session_id}/created-objects🔒 auth

Lists the assets this session created.

{"objects": [{"id": 31, "asset_type": "dashboard", "asset_id": 1290,
"asset_name": "Revenue per branch", "created_at": "...",
"still_exists": true}]}

Read each asset back and check it does what was asked: the final message says what Cobuild thinks it did.

Undo​

POST/api/cobuild/sessions/{session_id}/undo🔒 auth

Body: UndoRequest; send {} for "undo everything this session did".

{"object_ids": null, "force": false, "mode": "all", "mutation_ids": null}

mode: "all" deletes the assets the session created and reverts its edits; "creations" deletes created assets only. Response:

{"deleted": [...], "blocked": [...], "skipped_missing": [...], "reverted": [...], "not_reverted": [...], "superseded": [...]}

An asset something else still references is blocked (with the references), not deleted. Undo before deleting the session — deleting it first loses the list of what it created.

Stop a run​

POST/api/cobuild/threads/{thread_id}/abort🔒 auth

No body.

A running turn stops at its next step and never applies a pending change. Response:

{"status": "cancelling", "thread_id": 988, "thread_status": "cancelled"}

or {"status": "noop", ...} when nothing was running. A paused thread is not "running": reject the pending proposal instead.

HTTP 409: states that refuse a call​

Call409 detail (abridged)What to do
run"A run is already in progress on this thread"poll until it stops, or abort
run"This thread is paused awaiting your approval of a pending action"resolve it with resume / tool-permission
run"This thread is paused on a question Cobuild asked you"answer it
run"Nothing new to run: this thread already has messages"send prompt
run, resume, messages"This Cobuild session is archived"start a new session
resume"This approval has already been applied"read the thread; it ran once and will not run again
resume"This message is no longer awaiting approval"read the latest message
answer (via resume)"This question has already been answered"read the thread
tool-permission"No pending tool-permission request on this thread"read the thread
tool-permission"request_id does not match the pending request (stale?)"use the current request_id
answer"No pending question on this thread" / "request_id does not match the pending question"read the thread

Other answers: 400 for a malformed body, 403 for a missing permission (permission_denied, cobuild_scope_denied), 404 for a session, thread or message outside your organisation and project (never 403 — ids are not probeable), 502 when the model provider failed.

Live progress with Server-Sent Events​

GET /api/cobuild/messages/{message_id}/stream?token=<session token>&project_id=<project id>

Browsers' EventSource cannot send headers, so the token and project ride the query string. A PAT is refused here (a query string ends up in logs and history); use a short-lived login token, or poll instead.

Events: snapshot first (the text and trace so far), then delta ({turn, text}), turn_end, trace, heartbeat every 15 s, and exactly one terminal event — paused (refetch the thread and resolve the pause), done, or error. A turn that already finished answers snapshot plus its terminal event and closes.

The Cobuild MCP server​

The same loop as MCP tools, for agents that speak the Model Context Protocol.

EndpointPOST https://<your-host>/api/cobuild/mcp
TransportMCP Streamable HTTP; one JSON-RPC 2.0 object per request, a JSON answer (no SSE stream; GET / DELETE answer 405)
SwitchOff by default. An administrator turns it on with MCP_COBUILD_ENABLED=true in the platform's environment. Off, the endpoint answers 404
AuthEither per-user sign-in (v0.3.101 or newer) or exactly as in Authentication: Authorization: Bearer <PAT>, X-Project-Id, and X-Org-Id when needed. Without a valid credential the endpoint answers 401 with a WWW-Authenticate header that points the client to the sign-in

Every tool runs as the token's user, through the same Cobuild routes as the loop. Nothing is approved automatically: runs never use force, a pause is only resolved by an explicit cobuild.approve (naming the message or the permission request) or cobuild.answer, scope: "always" is refused, and the delete options drop_data / delete_orphan_insights are never set (they stay off). Approvals are the user's decision — an agent shows the pending payload to the user and relays their verdict.

ToolArgumentsResult
cobuild.askprompt; session_id, thread_id (follow-up); workstream_kind (default dashboard), title (new session); read_only{session_id, thread_id, message_id, action} — a background run was started
cobuild.statussession_id; thread_id (default the first thread); message_id (default the latest assistant message){status, message_id, latest_message_id, termination, pending, text, tool_calls_count, thread_status, session_id, thread_id}
cobuild.approvethread_id, approved; then message_id (a plan, with optional note) or request_id (a tool permission, with scope once / session)the resumed turn: {action, message_id, ...}
cobuild.answerthread_id; answer (an option's text, its 0-based index, or free text) or rejected: true; optional request_idthe resumed turn
cobuild.created_objectssession_id{objects: [...]}
cobuild.abortthread_id{status, thread_id, thread_status}

status is one of running, awaiting_approval (pending = the proposal), awaiting_permission (pending = the permission request, with its request_id), awaiting_answer (pending = the question, with its options and request_id), done, failed. A cancelled turn reports done with termination: "cancelled".

A plan approval resumes in the background — poll cobuild.status after it. A tool-permission approval answers when the resumed turn stops.

Errors from Cobuild come back as JSON-RPC errors with code -32000, the message, and data.http_status plus the original data.detail — for example every 409 above. Bad arguments are -32602.

Example — ask, then poll​

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "cobuild.ask",
"arguments": {"prompt": "Build a dashboard of monthly revenue per branch from fact_revenue",
"title": "test: revenue per branch"}}}
{"jsonrpc": "2.0", "id": 1, "result": {
"content": [{"type": "text", "text": "{\"session_id\":412,\"thread_id\":988,\"message_id\":5120,\"action\":\"accepted\"}"}],
"structuredContent": {"session_id": 412, "thread_id": 988, "message_id": 5120, "action": "accepted"},
"isError": false}}
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "cobuild.status", "arguments": {"session_id": 412, "thread_id": 988}}}

A refused approval scope:

{"jsonrpc": "2.0", "id": 3, "error": {"code": -32602,
"message": "scope 'always' is refused by the Cobuild MCP server: it writes a standing project policy. The user can set it in Honeyframe (Cobuild tool permissions)."}}

A run refused because the thread waits on an approval:

{"jsonrpc": "2.0", "id": 4, "error": {"code": -32000,
"message": "This thread is paused awaiting your approval of a pending action. ...",
"data": {"http_status": 409, "detail": "This thread is paused awaiting your approval of a pending action. ...",
"session_id": 412, "thread_id": 988}}}

Connecting a client​

Add a remote (HTTP) MCP server with the endpoint above, and either let it sign in (OAuth, v0.3.101 or newer) or send the headers from Authentication. Clients that do not allow dots in tool names may call cobuild_ask, cobuild_status, and so on; the server accepts both.

Sign in with your own account​

An MCP client that supports OAuth (a custom connector in an AI assistant, a coding agent, the MCP Inspector) can sign each person in with their own Honeyframe account instead of sharing a PAT. Nothing has to be created in advance:

  1. The client calls the MCP endpoint without a credential and gets 401 with WWW-Authenticate: Bearer resource_metadata="https://<your-host>/.well-known/oauth-protected-resource/api/cobuild/mcp".
  2. It reads that document — GET /.well-known/oauth-protected-resource/api/cobuild/mcp (also served without the suffix as GET /.well-known/oauth-protected-resource): resource is the MCP URL, authorization_servers your Honeyframe host, scope cobuild. It then reads the provider metadata at GET /.well-known/oauth-authorization-server (also served as /.well-known/openid-configuration).
  3. It identifies itself with a Client ID Metadata Document — its client_id is an https URL — so no client registration is needed. PKCE (S256) is always required. Only documents on hosts the administrator allows can sign in (MCP_OAUTH_CIMD_ALLOWED_HOSTS; * allows any host).
  4. The browser opens Honeyframe's sign-in and consent page. The person sees the requesting application's host (a program on their own computer shows as localhost, with a warning), picks the project Cobuild may work in, and clicks Allow.
  5. The client exchanges the result at the token endpoint, POST /api/oidc/token, and receives an access token (valid 8 hours) and, when it asked for offline_access, a refresh token (30 days). The same endpoint accepts grant_type=refresh_token; each refresh token is replaced by a new one every time it is used, and a used, expired or revoked one is refused with invalid_grant.

The access token works only on the Cobuild MCP endpoint, as that person, in the project they chose — no X-Project-Id / X-Org-Id headers are needed. Every call still checks their own permissions: project.edit and the Cobuild use permission, exactly as for a PAT. If they lose access to the project, the next call answers 401 and the client asks them to sign in again.

Disconnecting an application​

The person removes the connector in their client, or revokes the sign-in in Honeyframe: profile → Personal access tokens → Connected applications lists every application signed in with their account, with a Revoke button on each. Revoking stops the refresh token and the access token at once.

Through the API (with the same Authorization header as in Authentication):

  • GET /api/oidc/grants lists the applications signed in with your account.
  • DELETE /api/oidc/grants?client_id=<client ID> revokes one, with the client ID URL-encoded as a query parameter. Use the query form: a client ID is usually an https:// URL, and a proxy in front of Honeyframe can rewrite it when it sits in the path. Revoking an application that is not signed in answers 404.

Start from an open session: Salin sebagai cURL​

An open Cobuild conversation has a Salin sebagai cURL ("Copy as cURL") button in its header, next to Export JSON and Export Markdown. It copies a shell snippet, pre-filled with this host and the session, thread, organisation and project ids, that:

  1. runs one turn in the background (POST /api/cobuild/sessions/{session_id}/run with a prompt placeholder and background: true),
  2. reads the thread's messages (GET /api/cobuild/threads/{thread_id}),
  3. polls the latest message every 5 seconds until the thread is no longer running, printing thread.status and plan_state.termination.

The token in the snippet is always the placeholder <your PAT> in an HF_TOKEN variable — your browser's own login token is never copied. Replace it with a PAT that meets the requirements above, and handle approvals, tool permissions, questions and aborts with the calls in the loop.