Development Preview · PR #2551 · 58d61b2 · built
Skip to content

REST API Examples

The SynthOrg REST API is mounted at /api/v1 on the backend service (default port 3001). Every endpoint requires authentication; the JWT is delivered as an HttpOnly Set-Cookie header by /auth/login, so subsequent calls authenticate by carrying the cookie back, not by attaching an Authorization: Bearer header. The response envelope is a typed ApiResponse<T> or PaginatedResponse<T>. This guide shows the 10 most common operations.

The base URL placeholder $BASE defaults to http://localhost:3001. Examples assume jq is installed for response inspection.

Authenticate

curl

# Login. -c writes the session cookie to a jar; -b on every subsequent
# call reads it back. The response body carries only metadata
# (expires_in, must_change_password); the JWT is in Set-Cookie.
curl -s -c cookies.txt -X POST $BASE/api/v1/auth/login \
  -H "Content-Type: application/json" \
  --data '{"username":"admin","password":"admin"}' | jq

Python (httpx)

import httpx

# httpx.Client persists cookies on its ``.cookies`` jar between calls.
client = httpx.Client(base_url="http://localhost:3001")
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
resp.raise_for_status()
# Token is in client.cookies now; every subsequent client.get/post
# carries it back automatically.

JavaScript (fetch)

// credentials: 'include' both sends and accepts cookies. In a browser
// this works against same-origin or CORS-allowed targets; in Node 18+
// fetch use undici's cookie jar via dispatchers (see node docs).
const resp = await fetch('http://localhost:3001/api/v1/auth/login', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ username: 'admin', password: 'admin' }),
})
const { data: session } = await resp.json()
console.log('session expires in', session.expires_in, 'seconds')

1. List agents

curl -s -b cookies.txt "$BASE/api/v1/agents" | jq
agents = client.get("/api/v1/agents").json()["data"]
const r = await fetch(`${base}/api/v1/agents`, { credentials: 'include' })
const { data: agents } = await r.json()

Returns a paginated envelope; the meta.next_cursor field drives the next page.

2. Create a task

curl -s -b cookies.txt -X POST "$BASE/api/v1/tasks" \
  -H "Content-Type: application/json" \
  --data '{"title":"Build a sample","description":"Smoke test","type":"development","project":"'"$PROJECT_ID"'","created_by":"'"$AGENT_NAME"'"}'
resp = client.post(
    "/api/v1/tasks",
    json={
        "title": "Build a sample",
        "description": "Smoke test",
        "type": "development",
        "project": project_id,
        "created_by": agent_name,
    },
)
task = resp.json()["data"]
const r = await fetch(`${base}/api/v1/tasks`, {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Build a sample', description: 'Smoke test', type: 'development', project: projectId, created_by: agentName }),
})
const { data: task } = await r.json()

3. Get a task

curl -s -b cookies.txt "$BASE/api/v1/tasks/$TASK_ID" | jq
task = client.get(f"/api/v1/tasks/{task_id}").json()["data"]

4. List artifacts for a task

curl -s -b cookies.txt "$BASE/api/v1/artifacts?task_id=$TASK_ID" | jq
resp = client.get("/api/v1/artifacts", params={"task_id": task_id})
artifacts = resp.json()["data"]

5. Submit a client request

curl -s -b cookies.txt -X POST "$BASE/api/v1/requests" \
  -H "Content-Type: application/json" \
  --data '{"client_id":"c-1","requirement":{"title":"Ship the thing","description":"Make it work","acceptance_criteria":["Tests pass"]}}'
resp = client.post(
    "/api/v1/requests",
    json={
        "client_id": "c-1",
        "requirement": {
            "title": "Ship the thing",
            "description": "Make it work",
            "acceptance_criteria": ["Tests pass"],
        },
    },
)

6. Approve a client request

curl -s -b cookies.txt -X POST "$BASE/api/v1/requests/$REQUEST_ID/approve"

The approve endpoint walks the request through the intake engine (when in SUBMITTED status) or finalises a previously-scoped request.

7. Fetch budget utilisation

curl -s -b cookies.txt "$BASE/api/v1/analytics/overview" | jq
overview = client.get("/api/v1/analytics/overview").json()["data"]
print(f"Budget used: {overview['budget_used_percent']:.1f}%")

8. Approve a pending approval

Approvals are decided through dedicated /approve and /reject endpoints (there is no combined /decide route). /approve accepts an optional comment; /reject requires a mandatory reason.

curl -s -b cookies.txt -X POST "$BASE/api/v1/approvals/$APPROVAL_ID/approve" \
  -H "Content-Type: application/json" \
  --data '{"comment":"Canary signal clean."}'
resp = client.post(
    f"/api/v1/approvals/{approval_id}/approve",
    json={"comment": "Canary signal clean."},
)
# To reject instead (reason is mandatory):
# client.post(f"/api/v1/approvals/{approval_id}/reject", json={"reason": "Needs rework."})

9. Subscribe to the live event WebSocket

The WebSocket uses a two-step ticket handshake: exchange your session for a one-time ticket, then send it as the first frame after the socket opens.

// 1. Exchange the session cookie for a one-time WebSocket ticket.
const ticketResp = await fetch(`${base}/api/v1/auth/ws-ticket`, {
  method: 'POST',
  credentials: 'include',
})
const { data: { ticket } } = await ticketResp.json()

// 2. Open the socket and authenticate with the ticket on the first frame.
// Derive ws/wss from the API base so TLS is preserved: an https base
// yields wss://. Plain ws:// is acceptable only for a localhost base; in
// any deployment the ticket and event data travel in-band and ws:// would
// expose them to network observers.
const ws = new WebSocket(`${base.replace(/^http/, 'ws')}/api/v1/ws`)
ws.onopen = () => {
  ws.send(JSON.stringify({ action: 'auth', ticket }))
}
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data)
  if (msg.action === 'auth_ok') {
    // 3. Once authenticated, subscribe to channels.
    ws.send(JSON.stringify({ action: 'subscribe', channels: ['tasks', 'approvals'] }))
    return
  }
  console.log('[event]', msg.event_type, msg.payload)
}

The server's auth acknowledgement frame is {"action":"auth_ok"}; once seen, the channels you subscribed to deliver events in real time. See the WebSocket Models section of the API reference for the full handshake and event-type catalogue.

10. Query the project brain

The project brain records a project's decisions, open questions, blockers, risks, dependencies, and plan revisions. The read endpoints are available whenever persistence and a memory backend are wired.

# Current-state blockers for a project, newest first.
blockers = client.get(
    "/api/v1/projects/proj-abc123/brain",
    params={"entry_kind": "blocker", "status": "blocked", "limit": 20},
).json()

# Semantic search across all entries.
hits = client.get(
    "/api/v1/projects/proj-abc123/brain/search",
    params={"q": "payment integration risk", "limit": 5},
).json()

11. Steer a running project

Mid-flight steering injects a hint or redirect into a project without stopping it. A redirect forces affected agents to re-plan at their next safe boundary; supersede_mode controls how obsolete tasks are cancelled.

result = client.post(
    "/api/v1/cockpit/steering",
    json={
        "project_id": "proj-abc123",
        "kind": "redirect",
        "text": "Use Postgres instead of MongoDB for all persistence work",
        "supersede_task_ids": ["task_xyz789"],
        "supersede_mode": "explicit",
    },
).json()
directive_id = result["data"]["directive_id"]

12. Resume a pending interrupt

When an agent pauses for human input (a tool approval or a clarification request), it raises an interrupt. Poll for pending interrupts, then resume one with a decision or a response.

# List pending interrupts (optionally filter by ``session_id``).
pending = client.get("/api/v1/interrupts").json()
interrupt_id = pending["data"][0]["id"]

# Approve a tool-approval interrupt.
client.post(
    f"/api/v1/interrupts/{interrupt_id}/resume",
    json={"decision": "approve", "feedback": "Looks correct, proceed."},
)

# Or answer an information-request interrupt instead:
# client.post(
#     f"/api/v1/interrupts/{interrupt_id}/resume",
#     json={"response": "Target the EU region."},
# )

The streaming counterpart to this polling API is the Server-Sent Events stream at /api/v1/events/stream; the polling endpoints above are the fallback when a long-lived SSE connection is impractical.

Pagination

List endpoints return PaginatedResponse<T>:

{
  "data": [...],
  "meta": {
    "limit": 50,
    "next_cursor": "eyJsYXN0X2lkIjoidGFzay0xMjMifQ==",
    "has_more": true
  }
}

To fetch the next page: pass ?cursor=<value> to the same endpoint. Stop when has_more is false.

Error envelopes

Errors follow RFC 9457:

{
  "type": "synthorg/not-found",
  "title": "Task not found",
  "status": 404,
  "detail": "Task '123e4567-e89b-12d3-a456-426614174000' not found",
  "code": "RESOURCE_NOT_FOUND",
  "category": "client_error"
}

The code field is the typed ErrorCode enum (see docs/reference/errors.md). Clients can switch on the enum without parsing prose.