This guide describes common routes. The generated HTTP catalog is the generated mounted-route inventory and links each route to its implementation. Most routes use /api; hosted artifacts, the gateway, and /v1 have separate paths. Authentication can use a session cookie or Bearer token. Public health/authentication routes and signed webhook/SSO flows have their own requirements.
For scripts, use an Authorization: Bearer <token> header. The browser normally uses an HttpOnly session cookie. Throughout these docs the placeholder is $OCTIPUS_API_TOKEN. There are two ways to get a token:
Login JWT — POST /api/auth/login-mobile returns a session token in the body for native/script clients. POST /api/auth/login sets the browser cookie and does not return the token in JSON. Sessions expire.
Personal Access Token — create a long-lived token under Settings → API Tokens in the web UI (or POST /api/auth/api-tokens). Best for scripts, cron, and webhooks. Manage them via the API Tokens endpoints; revoke with DELETE /api/auth/api-tokens/:id.
Admin-only endpoints (anything marked admin, plus system-scoped vault writes) require a token belonging to an admin user. Export it once:
Register new user (audited as user_created); security.registration applies, inviteToken? is redeemed with the account (SPACES.md → Registration modes)
GET
/api/auth/registration
No
{mode, firstAccount}: the registration mode
POST
/api/auth/login
No
Login with credentials; a TOTP account without totpCode gets 401 { requiresTOTP: true } (audited as login / login_failed)
POST
/api/auth/login-mobile
No
Login returning bearer token in response body (for native clients)
POST
/api/auth/logout
Yes
Logout and invalidate session
GET
/api/auth/me
Yes
Get current user info
GET
/api/auth/ws-ticket
Yes
Get a short-lived (60 s) token to sign a socket in: the gateway auth frame, or the /voice URL
POST
/api/auth/passkey/register/options
Yes
Generate WebAuthn registration options
POST
/api/auth/passkey/register/verify
Yes
Verify WebAuthn registration response
POST
/api/auth/passkey/auth/options
No
Generate WebAuthn authentication options
POST
/api/auth/passkey/auth/verify
No
Verify WebAuthn authentication response
POST
/api/auth/totp/setup
Yes
Setup TOTP 2FA
POST
/api/auth/totp/enable
Yes
Enable TOTP after verification
POST
/api/auth/totp/disable
Yes
Disable TOTP 2FA
POST
/api/auth/link
Yes
Redeem channel link code
Login, login-mobile and register take an optional returnTo: a same-origin
path (one leading /, no //, no backslash, no whitespace or control
characters). Anything else is refused with 400; a valid one is echoed back as
returnTo in the response (/ when none was sent).
A user’s own model rows, run with their own key (SPACES.md → Own
models). Owner-only, admins included; another user’s
model answers 404. Bodies are validated against an allowlist (400 otherwise).
Method
Endpoint
Description
GET
/api/me/models
My models, with the allowed providers and the topics I may bind
List all roles: tools, lane, description, prompt, critical rules, read-only flag
POST
/api/roles
Create a role (admin). Body: { role, systemPromptTemplate, toolIds, description?, coreToolIds?, criticalRules?, defaultTopic?, readOnly? }
PATCH
/api/roles/:role
Update a role (admin). Every field optional; absent = unchanged
DELETE
/api/roles/:role
Delete a user-created role (admin). A role that ships with Octipus returns 400
A role’s LANE is its defaultTopic resolved through canonicalTopic — the
model binding it runs on. Nothing selects a role out of a lane; a role is chosen
by an agent naming it in spawn_child, off its one-line description.
Create and start a pipeline from a template. Returns 202 with { pipelineId, sessionId } as soon as the run has an id — the run itself continues in the background.
GET
/api/pipelines
List user’s pipeline runs
GET
/api/pipelines/:id
Get pipeline detail with nodes
POST
/api/pipelines/:id/stop
Stop a running pipeline
GET
/api/pipelines/:id/plan
List the plan a foreach node iterates
POST
/api/pipelines/:id/plan
Append plan items (editable while running)
PATCH
/api/pipelines/:id/plan/:itemId
Edit or reorder a plan item
DELETE
/api/pipelines/:id/plan/:itemId
Drop a still-pending plan item
POST
/api/pipelines/:id/pause
Pause at the next node boundary
POST
/api/pipelines/:id/resume
Resume from the newest checkpoint, or from fromSeq (rewind)
Per-user root agent persona — name, tone, narration volume, free-form self-facts — plus the per-arm voices that shadow it. Same controls as the /persona slash command (see CHAT-COMMANDS.md) and the web /persona page. Writes delegate to handlePersonaCommand so validation matches across all three surfaces.
Method
Endpoint
Description
GET
/api/persona
Resolved persona for the current user (name, pronouns, tone, narration, presetId, signaturePhrases, userFacts)
GET
/api/persona/presets
List shipped persona presets (personas/*.yaml) — no auth
PATCH
/api/persona
Update one or more of { name, tone, narration, presetId }
POST
/api/persona/facts
Append a free-form self-fact ({ fact }, 4–280 chars)
DELETE
/api/persona/facts/:idx
Remove the N-th free-form fact (0-indexed across extra:*)
POST
/api/persona/reset
Restore Octipus default — drops custom name and facts
GET
/api/persona/arms
Per-arm persona bindings ({ arms: { review: "terse-engineer" } }); {} when no arm has its own voice
PUT
/api/persona/arms/:role
Shadow one arm’s voice with { presetId }. 400 with the reason for an unknown role/preset; for the root identity use PATCH /api/persona
DELETE
/api/persona/arms/:role
Clear it — that arm runs with no persona, as before
An override body contains toolId, action, level (ALLOW, ASK, DENY),
optional reason, and optional scope. A scope requires a future expiresAt
and at least one of sessionId, workspaceId, pathPattern, commandPattern.
Scope conditions are combined; unavailable/mismatched scope evidence blocks.
A scoped ALLOW cannot overwrite an existing DENY (409); review that denial
first. Removing an override restores the manifest default, which may be ALLOW;
set ASK explicitly when the intent is to require approval.
Direct execution accepts { "args": { ... } } and runs unattended. ASK returns
HTTP 409 with code: "approval_required"; other execution failures return
400 with code: "tool_execution_failed". An attended agent’s descendants can
use the session’s approval surface. MCP permissions use tool ID mcp, action
<serverId>.<remoteToolName>. Argument patterns constrain authorization, not
filesystem or shell isolation.
Send a chat message. Without a channel that can show an approval prompt, the turn is unattended: actions at ASK are refused, not asked. Interactive answers are replaced by a plain line unless the body declares renders: ["inline_ui"]; toolsUsed: true adds the tool names the turn called (the eval runner)
List user’s tasks (?status=, ?due=today, ?category=, ?view=next for next-action order with a bucket and reason each)
GET
/api/tasks/:id
Get one task
POST
/api/tasks
Create a task (parentId, blockedBy[], estimate optional)
PATCH
/api/tasks/:id
Update task; status is open / in_progress / done / archived; parentId: null and blockedBy: [] clear the links
DELETE
/api/tasks/:id
Delete task (its sub-tasks become top-level)
GET
/api/tasks/role-agents
The caller’s role agents: task counts, on/off, pipelineTemplate (the template each task runs through, or null)
PUT
/api/tasks/role-agents
Turn a role agent on or off ({role, enabled}); with enabled: true, pipelineTemplate (an id or name) makes it run each task through that pipeline and null makes it work tasks itself. A template with a required parameter is refused
A parent or blocker the caller cannot see, a self-link, or a parent loop is a
400. Only an active (open or in-progress) blocker blocks; a done one is
inert and stays in the array.
What is already set up, for the setup guide (models, CLI tools, areas, mailbox, connectors, chats, heartbeat, permissions, role agents, templates, saved progress). Install-wide details for admins only
PUT
/api/onboarding/progress
Save the guide’s progress (dismissed, useCases, answers without secrets, completed)
PUT
/api/onboarding/standing-instructions
Write (or append to) the pinned user-level heartbeat note ({body})
The caller’s heartbeat: enabled, intervalMinutes (null = the server’s), watchEmail, watchEmailSince, the server settings, the connected mailbox, whether standing instructions exist, and the last 10 outcomes of the inbox watch
Create user in the token’s org (a member with that userName is returned as-is; one held outside the org is 409 uniqueness)
GET
/api/scim/v2/Users/:id
Get user
PATCH
/api/scim/v2/Users/:id
Apply supported SCIM user updates; userName/emails only on an account the org alone holds (no other org, not an install admin, no own password), else 403
DELETE
/api/scim/v2/Users/:id
Remove the user from the token’s org; deactivate when no other org holds them
Change an enrolment’s mode, quiet hours or rate limit (admin, audited)
DELETE
/api/admin/group-channels/:id
Remove an enrolment (admin, audited)
Requests and approvals are answered by their requester on every other path
(REST and the gateway); these two routes are the admin’s only way to answer
for someone else.
Shared workspaces (SPACES.md). A caller who is not a member gets
404 for every space id; a member whose role lacks the action gets 403
(forbidden_role); last_owner, space_full, archived,
not_purgeable and funding_off answer 409. Bodies reject unknown fields (422).
Method
Endpoint
Who
Description
GET
/api/spaces
any user
My spaces with my role
POST
/api/spaces
per spaces.creation
Create a space {name}; the caller becomes its owner
GET
/api/spaces/:id
member
Name, my role, member count, archived, funding, sponsorUserId, sponsorModels
PUT
/api/spaces/:id/funding
owner
{mode?: "own" | "unattended" | "sponsored", sponsor?: "me" | null, sponsorModels?}; only the sponsor sets sponsorModels (their own model rows)
GET
/api/spaces/:id/budget
member
The space’s budgets with this period’s spend; the member cap with my own share
PUT
/api/spaces/:id/budget
owner
{kind: "space" | "space_member", period: "day" | "month", limitUsd: number | null, warnRatio?}; null removes it
PATCH
/api/spaces/:id
owner
Rename {name}
POST
/api/spaces/:id/archive
owner
Make read-only and stop its agents
POST
/api/spaces/:id/unarchive
owner
Undo archive
DELETE
/api/spaces/:id
owner
Delete for good; only archived for spaces.purgeAfterArchiveDays
GET
/api/spaces/:id/members
member
{userId, username, role, joinedAt}
PATCH
/api/spaces/:id/members/:userId
owner
Change role {role, scope?}; scope = {rooms, folders} for guests (SPACES.md → Guests)
DELETE
/api/spaces/:id/members/:userId
owner, or self
Remove a member, or leave
GET
/api/spaces/:id/invites
owner
Invites (never the token)
POST
/api/spaces/:id/invites
owner
{role, scope?, expiresInHours?, maxUses?} → {id, token, role, expiresAt, maxUses}; the token is shown once
DELETE
/api/spaces/:id/invites/:inviteId
owner
Revoke an invite of this space
GET
/api/spaces/:id/activity
member
Audit rows of the space, newest first; ?limit=&before=
PUT
/api/spaces/:id/agent-edit-mode
owner
{mode: "suggest" | "direct"}: whether the agent’s note writes become edit proposals
GET
/api/spaces/:id/file-leases
member
Live file leases (“Ben is editing”): {path, holderUserId, holderName, holderKind, expiresAt}
POST
/api/spaces/:id/file-leases
editor, owner
{path, renew?} take (or renew) the lease on a space file; 409 lease_held with heldBy when someone else has it (or a directory above it, or a file under it)
DELETE
/api/spaces/:id/file-leases?path=
member
Release your lease
GET
/api/spaces/:id/connectors
member
The space’s connectors (GitHub, Atlassian, Linear): {id, name, kind, connected, connectedBy, connectedAt}; never a value
POST
/api/spaces/:id/connectors/:connectorId
owner
Connect: {token} for GitHub; an OAuth connector returns {url} for the popup, whose callback stores the tokens under the space
DELETE
/api/spaces/:id/connectors/:connectorId
owner
Disconnect (the space’s secrets of that connector are deactivated)
In a space, POST /api/notes takes baseSha256 (the sha of the body the
edit was made from — GET /api/notes/:id returns the live text and its sha
as bodySha256 while the note is open in an editor). The edit is merged with
what others wrote since, or refused with 409 {code: "stale", currentSha256}; a note over spaces.noteMaxBytes is a 413. A body write
to an existing space note without baseSha256 is a 400 {code: "base_required"}: read the note first. Omitting body on an existing space
note saves only its title, tags and kind. Line endings are stored as \n.
{base, text}: merge a live editor’s text the server never got (typed offline, or unsent when the document was rebuilt) from base, the last server text it synced, as the member’s typing → {changed, merged, sha256, revisionId}; 409 stale on a clash (nothing applied)
GET
/api/notes/proposals?noteId=&status=
member
The agent’s edit proposals
POST
/api/notes/proposals/:proposalId/accept
editor, owner
Apply it (merged through the live document); 409 {status: "stale", base, current, proposed} when it collides with a newer edit
Run the KB self-check (DB + embedding model + vector write probe). Returns 503 with { kb: { ready: false, reasons: [...] } } if the KB is not ready.
GET
/api/knowledge
Browse entries (lightweight, no vectors).
GET
/api/knowledge/stats
Counts per source type.
POST
/api/knowledge/search
Search (mode: hybrid / semantic / keyword). 503 if KB not ready.
GET
/api/knowledge/:id
Full entry.
DELETE
/api/knowledge/:id
Delete entry.
POST
/api/knowledge/cleanup
Orphan / stale / short / duplicate cleanup with optional dryRun.
GET
/api/knowledge/cleanup-history
Recent cleanup runs.
POST
/api/knowledge/index
Index a file or directory. 503 if KB not ready.
Every knowledge route works on the caller’s own entries plus the product docs
(another user’s entry is a 404). An admin adds ?scope=install to list, read,
search, delete, clean up or count across the whole install, or to see every
cleanup run; each such request writes a knowledge_install_access audit row.
?scope=install from a non-admin is a 403.
mode='graph' is available to agents through the knowledge tool, not on
this route — see KNOWLEDGE-GRAPH.md.
Authored markdown notes — the knowledge graph’s Tier 2 surface. Full model in
KNOWLEDGE-GRAPH.md. Every route works in the request’s
workspace (X-Octipus-Workspace): it sees that workspace’s notes plus
user-level ones, and creates new notes in it. No route takes a workspaceId
in its body.
Method
Endpoint
Description
GET
/api/notes
List notes. Query: kind, tag, includeArchived, limit (max 500).
POST
/api/notes
Create or update a note (re-links [[wikilinks]]/#tags and re-indexes).
Global mode: active notes + resolved edges (max 2000 nodes / 5000 edges). With entryType + entryId: local neighbourhood, hops clamped 1–5. Ghost (unresolved) edges are never returned.
GET
/api/graph/canvas
JSON Canvas projection of a neighbourhood. Query: entryType, entryId, hops (1–5).
Group chats enrolled from inside the channel (CHANNELS.md).
Owner-only, admins included (the admin routes above are the audited
override); another user’s enrolment answers 404.
Method
Endpoint
Description
GET
/api/me/group-channels
My enrolled group channels
PATCH
/api/me/group-channels/:id
Set mode (mention/listen/proactive), quiet hours, timezone and rate limits
DELETE
/api/me/group-channels/:id
Remove the enrolment
POST
/api/me/group-channels/:id/bind
Bind the channel to a space I own: {workspaceId, acknowledged: true, roomId?}. roomId becomes the main thread; other threads get a room on first use. Audited (SPACES.md → Group channels bound to a space)
DELETE
/api/me/group-channels/:id/bind
Unbind it (the channel’s owner, or an owner of the space)
Tokens are shown once at creation; revoke and create a replacement to rotate.
An empty scope list is full access. Restrict automation tokens to the scopes
required by their routes; see src/security/scopes.ts for supported names.
A connector is a remote MCP server the user authorises once, per user, over
OAuth 2.1 with PKCE. Octipus registers itself as a public client dynamically
against the server’s own discovery document, so a connector is a definition
file (id, name, MCP endpoint, discovery URL, scopes) and nothing else — there
is no per-connector OAuth code. Built in today: Atlassian (Jira and
Confluence) and Linear.
Method
Endpoint
Description
GET
/api/connectors
List available connectors with this user’s connection status
POST
/api/connectors/:id/authorize
Start the OAuth flow; returns { url } to open in a popup
GET
/api/connectors/:id/callback
OAuth redirect target; posts connector:connected to the opener
DELETE
/api/connectors/:id
Disconnect — deletes this user’s stored tokens
Tokens live in the vault under connector_<id>_access_token /
_refresh_token / _token_expiry, per user; the dynamically registered client
and its endpoints are system-scoped under connector_<id>_client_id /
_auth_endpoint / _token_endpoint.
Every connected connector is reachable through the generic
connector_list_tools / connector_call_tool pair. Atlassian additionally has
named tools (atlassian_sites, jira_search, jira_get_issue,
jira_create_issue, jira_update_issue, jira_comment,
jira_transition_issue, jira_list_projects, confluence_search,
confluence_get_page, confluence_create_page, confluence_update_page),
held by the pm role. Those resolve the remote tool by capability rather than
by a hard-coded name — an ordered list of candidate names and argument names,
matched against the server’s own tools/list at call time — so a vendor
rename does not silently kill the tool group. When nothing matches, the error
names the remote tools that do exist.
Outgoing webhook delivery is configured as a hook action; there is no separate
outgoing-webhook CRUD API. See Hooks and Webhooks.
POST /api/webhooks/:path receives configured webhook triggers.
session_token — credentials.token: a login session token (web, TUI after
/login).
api_key — credentials.key: a personal API token (octi_…, Settings →
API Tokens).
artifact_token — credentials.artifactId and credentials.token: the
short-lived token an artifact embed page is served with. The connection is a
viewer of that one artifact, not a user: it may only ping and
(un)subscribe artifact:<id>. At most 50 viewer connections per artifact;
each closes when its token expires (code 4001), and all close when the
artifact is deleted or its visibility changes (code 4003; reload the page
for a new token).
A user holds at most gateway.maxConnectionsPerUser (default 20) signed-in
connections; the one over the cap gets auth_errorToo many connections.
A client frame larger than gateway.maxFrameBytes (default 262144 bytes,
echoed in auth_ok.maxFrameBytes) closes the socket with code 1009.
Every authenticated connection has user trust; there is no local trust and
no loopback exemption. Admin rights come from the user’s isAdmin flag in the
database. A connection whose user is deactivated or whose admin flag changes is
closed (codes 4001 and 4004) and must re-authenticate.
Send chat message (requires sessionId, content; optional workspaceId — one of yours — for a session the server has not seen yet, fileRefs, outputMode, attachments). A message sent while a turn of that session runs steers it (chat.message with injected: true)
chat.steer
Inject a message into the running turn of one of your sessions (a normal chat.send when none runs)
chat.interject
A side question answered alongside a running turn
command
Execute gateway command (name, optional args)
subscribe
patterns: event-type patterns over the connection’s own events (e.g., ["agent.*"]); resources: resources to receive events of (e.g., ["artifact:<id>"]), each access-checked and answered with subscribed or a FORBIDDEN error
unsubscribe
Remove event patterns and/or resources
permission.respond
Approve/deny permission request
approval.respond
Approve/deny pipeline approval
agent.stop
Stop one of the connection’s own user’s running agents (an admin included; another user’s agent is AGENT_NOT_FOUND)
voice.set
{ sessionId, on } — voice mode for one of your sessions on this connection: lifecycle narration arrives as voice.speak events; cleared when the connection closes. Another user’s session is SESSION_NOT_FOUND
replay
{ sessionId, afterEventId? } — the buffered events of one of your existing sessions after afterEventId. Another user’s or an unknown session is SESSION_NOT_FOUND
Gateway event (agent lifecycle, chat response, etc.); only the connection’s own user’s events, plus those of subscribed resources
subscribed
Resources of a subscribe that passed the access check
command.result
Result of a command
error
Error with code and message
pong
Heartbeat response with server time
permission.pending
{ requests, approvals }: your open permission requests and root-agent approvals, after every subscribe with patterns (events raised while it is read follow it) and after a permission.respond that found the request already answered
replay
{ sessionId, events, gap }: answer to replay; gap means the events cannot bridge what was missed — reload from REST
Events of note for clients (event.type): chat.response, chat.delta,
chat.error (a failed turn, to every connection of the user),
chat.message (injected steer, sideChannel answer, or proactive in-app
delivery to webchat:<your id>), permission.request / permission.resolved,
agent.approval_required / approval.resolved, document.*,
model.install_progress, voice.speak, swarm.*, agent.*. A refused
approval.respond answers errorAPPROVAL_NOT_FOUND (unknown, someone
else’s, or already answered) or APPROVAL_EXPIRED.
The same protocol, framed as JSON lines over the process’s own stdin and
stdout, for an IDE or a parent process that embeds Octipus as a subprocess:
octipus --stdio # or GATEWAY_STDIO=1
One line in is one client message (the auth line first, within 5 seconds),
one line out is one gateway message; lines end in \n only. Logs go to
stderr in this mode so the pipe carries nothing but protocol. Lines are
handled strictly in order. The WebSocket endpoint stays up alongside; the
stdio connection is one more client of the same hub, with the same rate
limits, budgets and event-visibility rule. When stdin ends, the process
shuts down.
/ws and /ws/permissions were removed (coworking S0d); the web app uses
/gateway. The browser extension’s /ws/browser-bridge and the voice
sockets (/voice, /voice/media/:provider) remain.
Octipus exposes an OpenAI-compatible surface at /v1 so off-the-shelf OpenAI
SDKs can talk to it. Authenticate with a personal access token
(Authorization: Bearer octi_…) exactly like /api. The api:chat scope is
enforced on completions (unscoped tokens are full-access — see
token scopes).
octipus/agent (default when model is omitted) runs the latest
user message through the full agent turn — the root agent with its tools,
delegating to specialists when it needs one. The older octipus/orchestrator ID remains accepted as a compatibility alias. This
mode is session-stateful: pass a stable user field (or an
X-Octipus-Session header) to keep a conversation sticky; otherwise each
call runs in a fresh ephemeral session.
A registry model id (e.g. gpt-4o, llama3.2, from GET /v1/models) is
a single-turn passthrough to that provider — no tools, no session — and
forwards the supplied text messages array. Provider-reported token usage
is returned. This is a limited text chat-completions surface, not full
OpenAI API compatibility (no multimodal content arrays or client tool schemas).