Server-side endpoints
Read Directives + agent runs, list apps, push Signals, nudge Directives through the lifecycle. Authenticate every call with an sk_ key.
sk_ keys can read any Directive in the Org and modify lifecycle state, never embed one in browser code or commit it to a repository.Authentication
Every route here requires an Org API key: Authorization: Bearer sk_…. Mint keys in the dashboard under Settings → API keys. Plaintext is shown once at creation; rotate via the same page.
Per-key rate limit is approximately 600 req/min (token bucket: capacity 100, refill 10/sec). Exceeded calls return 429 rate_limited with Retry-After.
GET /v1/apps
/v1/appsAuth: sk_ keyList every App in the authenticated Org.
Response (200)
{
"data": [
{
"id": "65fa1f3e8a1e5f2d9c1a5c01",
"slug": "marketing-site",
"name": "Marketing site",
"description": "Next.js + Vercel",
"publicKey": "pub_GLLHdYCLb2A2d9ieY2LmmwRJ",
"publicSubmissionEnabled": true,
"processingMode": "manual",
"batchEnabled": false,
"batchWindowHours": 24,
"allowedOrigins": ["https://acme.com"],
"createdAt": "2026-04-01T08:24:11.000Z"
}
]
}GET /v1/feedback
/v1/feedbackAuth: sk_ keyCursor-paged list of Directives, newest first. All filters are optional. The path is the stable V1 wire URL; the rows it returns are Directives — the promoted unit of work.
Query
| Name | Type | Description |
|---|---|---|
| appId | 24-char hex id | Restrict to one App. Default: all Apps in the Org. |
| status | string (DirectiveStatus) | Filter on a single status value (e.g. queued, executing, pr_opened, succeeded, archived). |
| cursor | 24-char hex id | The id of the last item from the previous page. Pass back as-is to continue. |
| limit | number (1–100) | Default 50. |
Response (200)
{
"data": [
{
"id": "69e2f2d0ad2f5eef87449d83",
"appId": "65fa1f3e8a1e5f2d9c1a5c01",
"status": "pr_opened",
"title": "Fix NaN cart total after coupon",
"text": "Several users report the cart total shows NaN after applying a coupon …",
"scale": "standard",
"sourceKindSummary": "widget",
"createdAt": "2026-04-18T09:11:12.000Z",
"completedAt": null,
"prUrls": ["https://github.com/acme/web/pull/4521"],
"primarySignalId": "65fb0a1c8a1e5f2d9c1a5d42"
}
],
"nextCursor": "69e2f2d0ad2f5eef87449d83"
}scale is the apparatus depth (quick / standard collapse to a single Worker; planned / epic add the Orchestrator + Validators). sourceKindSummary describes the source-kinds that drove the Directive (e.g. widget, cluster:multi), and primarySignalId points at the Signal that seeded it.
nextCursor is null when the page wasn't full, i.e. you're on the last page.
GET /v1/feedback/:id
/v1/feedback/:idAuth: sk_ keyFull Directive. Adds orgId, updatedAt, goal (the agent-facing goal statement), overrides, ownerBrief, and signalIds — every Signal attached to the Directive, which grows as connected signals attach. Per-submission payload (attachments, console logs, viewport, component target, submitter) lives on the underlying Signals, not the Directive.
Errors
| Name | Type | Description |
|---|---|---|
| 400 | invalid_id | id is not a valid 24-char hex id. |
| 404 | not_found | No Directive with that id in your Org (cross-Org reads return 404, not 403). |
PATCH /v1/feedback/:id
/v1/feedback/:idAuth: sk_ keyCustomer lifecycle nudge. The full FSM lives server-side; this endpoint exposes two legal moves:
Body
| Name | Type | Description |
|---|---|---|
| status | "queued" | "archived" | queued moves an approved / failed Directive into the agent queue (manual mode). archived dismisses from any non-terminal-success state. |
| ownerBrief | string (≤4000) | null | Owner-side additions to the agent prompt. null clears it. Frozen once the Directive reaches a terminal state. |
Response (200)
{ "id": "69e2f2d0ad2f5eef87449d83", "status": "queued" }Errors
| Name | Type | Description |
|---|---|---|
| 400 | invalid_body | invalid_id | no_op | Bad body, bad id, or empty patch. |
| 404 | not_found | Directive id not in this Org. |
| 409 | invalid_transition | Current status doesn't allow the requested move. Response includes from and to so the caller can render an explanation. |
| 409 | concurrent_update | invalid_state | Another writer changed the row mid-transition (retry safely), or you tried to set ownerBrief on a Directive that has already reached a terminal state. |
GET /v1/agent-runs/:id
/v1/agent-runs/:idAuth: sk_ keyPer-run audit row. Every run leaves one of these whether it produced a PR or failed.
Response (200)
{
"id": "65fb12cda0b8f9e2c0d33aef",
"appId": "65fa1f3e8a1e5f2d9c1a5c01",
"directiveId": "69e2f2d0ad2f5eef87449d83",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"status": "succeeded",
"tokensIn": 18432,
"tokensOut": 4211,
"costUsd": 0.231,
"prUrl": "https://github.com/acme/web/pull/4521",
"branch": "userz/directive-69e2f2d0",
"guardrailReason": null,
"error": null,
"timings": { "prepareMs": 812, "cloneMs": 1733, "agentMs": 142091, "pushMs": 644, "prMs": 1209 },
"startedAt": "2026-04-18T09:11:34.000Z",
"endedAt": "2026-04-18T09:13:48.000Z",
"createdAt": "2026-04-18T09:11:33.000Z"
}When status is guardrailed, guardrailReason explains why the diff was rejected before the PR opened (e.g. modified .env). When status is failed, error carries the cause.
Typed client
Hand-rolling these calls is fine, but a few lines of typed-client setup pays back fast:
import { createUserzApi } from '@userz-ai/api';
const userz = createUserzApi({ apiKey: process.env.USERZ_API_KEY! });
const apps = await userz.apps.list();
const { data, nextCursor } = await userz.directives.list({ limit: 50 });
const directive = await userz.directives.get(data[0].id);
await userz.directives.update(directive.id, { status: 'queued' });
const run = await userz.agentRuns.get(directive.id);Full reference at @userz-ai/api.