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:
| Requirement | Why |
|---|---|
read_only: false | Every 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 editor | The token never grants anything its owner does not have. |
| The user holds the Cobuild use permission | Organisations 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
| Header | Required | Meaning |
|---|---|---|
Authorization: Bearer <token> | always | The PAT or session token. |
X-Project-Id: <project id> | always | The 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 organisations | Selects 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
/api/cobuild/sessions🔒 authBody: 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
/api/cobuild/sessions/{session_id}/run🔒 authBody: 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'sinitial_promptis used. - Every follow-up: send the new text as
prompt(andthread_id). A bare run on a thread that already has messages answers 409. read_only: trueoffers Cobuild read tools only and refuses every write.modeloverrides the model for this run, as in the chat panel's model picker.- Do not send
force: truefrom an agent. It exists to clear a run that crashed and left the thread stuck onrunning; 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
/api/cobuild/threads/{thread_id}🔒 authReturns {"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.termination | Meaning | Next step |
|---|---|---|
streaming | still running | poll again (every 2–5 s) |
awaiting_approval with plan_state.tool_permission_request | a tool permission is pending | Approve or deny a tool permission |
awaiting_approval without it | a plan is pending | Approve or reject a plan |
awaiting_question | Cobuild asked the user a question (plan_state.question_request) | Answer a question |
end_turn (or absent) | finished; the answer is content | What was built |
cancelled | stopped by an abort | — |
error, max_iterations, timeout | failed; content explains | fix the prompt and run again |
approval_consumed, question_answered | this pause was already resolved; a later message carries the continuation | read 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
/api/cobuild/messages/{message_id}/resume🔒 authBody: 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
/api/cobuild/threads/{thread_id}/tool-permission🔒 authBody: ToolPermissionDecisionRequest, with request_id from plan_state.tool_permission_request.request_id.
{"request_id": "6f1c...", "decision": "approve", "scope": "once"}
decision:approveordeny.scope:once(this call),session(this tool, for the rest of the session — narrowed to the same target when the tool has one), oralways.alwayswrites 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
backgroundfield), so allow a long timeout and poll afterwards anyway.
Two field rules:
- The once-scope quirk. The first
onceapproval is often spent by Cobuild re-proposing the same call: you get a NEWrequest_idand must approve once more; the second one executes. Budget two rounds. - Batched proposals. When one proposal carries several calls (
tool_permission_request.batchlists them),onceapprovals do not converge — each re-proposes. Diff every call in the batch, then approve withscope: "session": it executes immediately and the grant ends with the session.
Answer a question
/api/cobuild/threads/{thread_id}/answer🔒 authBody: 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
/api/cobuild/sessions/{session_id}/created-objects🔒 authLists 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
/api/cobuild/sessions/{session_id}/undo🔒 authBody: 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
/api/cobuild/threads/{thread_id}/abort🔒 authNo 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
| Call | 409 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.
| Endpoint | POST https://<your-host>/api/cobuild/mcp |
| Transport | MCP Streamable HTTP; one JSON-RPC 2.0 object per request, a JSON answer (no SSE stream; GET / DELETE answer 405) |
| Switch | Off by default. An administrator turns it on with MCP_COBUILD_ENABLED=true in the platform's environment. Off, the endpoint answers 404 |
| Auth | Exactly as in Authentication: Authorization: Bearer <PAT>, X-Project-Id, and X-Org-Id when needed |
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.
| Tool | Arguments | Result |
|---|---|---|
cobuild.ask | prompt; 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.status | session_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.approve | thread_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.answer | thread_id; answer (an option's text, its 0-based index, or free text) or rejected: true; optional request_id | the resumed turn |
cobuild.created_objects | session_id | {objects: [...]} |
cobuild.abort | thread_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 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.
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:
- runs one turn in the background (
POST /api/cobuild/sessions/{session_id}/runwith apromptplaceholder andbackground: true), - reads the thread's messages (
GET /api/cobuild/threads/{thread_id}), - polls the latest message every 5 seconds until the thread is no longer running, printing
thread.statusandplan_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.