pipo. docs

REST, SSE and MCP

The engine (pipod) serves one HTTP server, the gateway, with five parts:

PathWhat
/in/<pipeline><path>Ingress: webhooks and API calls into pipelines' http inputs.
/api/…JSON REST: everything the CLI and the dashboard do.
/eventsServer-Sent Events: every journal event, live.
/mcpThe MCP endpoint for agents (see Agents as operators).
/uiThe dashboard and builder.

The gateway runs when engine.listen is set in config.yaml, or the engine is started with --listen:

pipo engine start --listen 8787     # 0 picks a free port
pipo engine status                  # prints the port; it is also in <home>/run/engine.json

It binds to 127.0.0.1 unless engine.host says otherwise (see Owner and remote access). Every CLI command has --json, which gives the same data as this API.

Conventions#

The owner only. With engine.auth: owner (the default), /api and /events need the owner's session cookie (from /api/auth/login) or the engine key, Authorization: Bearer <contents of <home>/engine.key>, which the CLI sends. Without either they answer 401 with code login_required, or owner_setup_required before the owner exists. /api/auth/* needs neither. See Owner and remote access.

Addressed to this engine. /api, /events, /mcp and the OAuth routes answer only requests whose Host is a loopback name (127.0.0.1, localhost, [::1]) or the host of engine.public_url, and refuse an Origin other than the engine's own. Anything else is 403. Every POST to /api must have Content-Type: application/json (415 otherwise). Together, these stop a web page in your browser from driving the engine (DNS rebinding, cross-site form posts). /in has no such limits: inputs have their own auth.

Errors are JSON with a status that matches:

{ "error": "no pipeline named 'people-intak' in this engine", "hint": "GET /api/pipelines lists them; start one with POST /api/pipelines {file} (pipo start <file>)", "code": "not_found" }
StatuscodeWhen
400bad_requestA missing or malformed field, an unknown key.
401login_required, owner_setup_required, login_failedNo session or engine key; no owner yet; a wrong username or password.
403forbiddenA Host or Origin that isn't this engine's, a wrong setup code, or a builder path outside the workspace.
404not_foundNo such pipeline, packet, version or route. An unknown route lists every route in routes.
405Wrong method.
409invalid_state, conflictThe pipeline isn't in a state that allows it (stopped, draining), a port is taken, a file exists.
413Body too large.
415A POST that isn't JSON.
422invalid_pipeline, rejectedThe file fails pipo check (with diagnostics), or a pushed packet was rejected (with packet_id).
429rate_limitedToo many failed logins, setup codes or reset links (with Retry-After).
502mail_failedThe mail server refused a test or reset email (the message has its answer).
503unavailableThe engine is still reattaching runners after a start (with Retry-After), or a runner is unreachable.

A diagnostics list has the shape of pipo check --json: code, severity, message, hint, line, col, path, file.

Names and ids. Pipeline names are [a-z0-9-]. Versions may be given as 3 or v3.

Reads without a runner. Packet, DLQ, version and proposal reads ask the runner when it runs. Otherwise they read the pipeline's journal read-only. The answer's source says which: runner or journal. A payload that can't be redacted safely without the runner (a secret that can't be resolved here) is withheld and listed in withheld.

Writes need a runner. DLQ replay and purge, rerun, rollback, proposals and resolve are committed by the runner. With the pipeline stopped they answer 409 with a hint to start it.

Owner#

MethodPathBodyAnswer
GET/api/auth{mode, owner, user, via, email, mail}: the mode (owner or open), whether an owner exists, who is asking (via: session or key), and whether the engine can send mail. With the engine key and no owner yet, also setup_code.
POST/api/auth/setup{code, username, password, email?}201 {user} and the session cookie. 403 for a wrong code, 409 once an owner exists.
POST/api/auth/login{username, password}{user} and the session cookie, or 401 login_failed.
POST/api/auth/logout{}Ends this session.
POST/api/auth/logout-all{}Ends every session (needs the owner).
POST/api/auth/password{current, password}Ends every other session; the caller gets a new cookie.
POST/api/auth/email{email}Sets the owner's email (null clears it). Not verified.
POST/api/auth/test-mail{}Sends a test email to the owner (409 without engine.mail or an email).
POST/api/auth/forgot{login}202, always: when login is the owner's username or email and mail works, a reset link goes out.
POST/api/auth/reset{token, password}Sets the password from a reset link (once, within 30 minutes), ends every session, and logs in.

Engine#

MethodPathBodyAnswer
GET/api/engine{engine_id, pid, home, started_at, listen, ready, stopping, pipelines, idle, ttl_expires_at, workspace, code_changed}
POST/api/engine/stop{}202 {stopping, pid, …}, then the engine stops as on pipo engine stop: pipelines drain, detached runners keep running.
POST/api/engine/restart{}202 {restarting, from, …}: hands over to a fresh engine on the same port.
GET/api/agent-budgetThe home's agent spend today: {per_day, spent_usd, window_start, resets_at, timezone, pipelines}. per_day is null with no engine cap.
POST/api/attach{name?}Scan the registry and reattach runners now, as pipo attach. {results}

code_changed: true means Pipo's code on disk changed since this engine started; the dashboard then offers a restart. GET /api/engine answers even while the engine is starting.

Pipelines#

MethodPathBodyAnswer
GET/api/pipelines{pipelines: [...]}, see below.
POST/api/pipelines{file, listen?, detached?}201: start the .pipo file at file, an absolute path.
GET/api/pipelines/<name>The pipeline, plus runner (the runner's status, while it runs: stats, paused_reason, awaiting_ack, last_seq…), lifetime and resources.
POST/api/pipelines/<name>/start{listen?, detached?}Start it again from its file.
POST/api/pipelines/<name>/stop{now?}Drain and stop, or stop at once with now: true.
POST/api/pipelines/<name>/drain{}Stop intake, finish in-flight packets, stop.
POST/api/pipelines/<name>/restart{listen?, detached?, ttl?}A deliberate stop, then a start.
POST/api/pipelines/<name>/pause{reason?}reason is manual (default) or agent. It keeps accepting packets.
POST/api/pipelines/<name>/resume{}
GET/api/pipelines/<name>/graph{name, version, source, nodes, definition}: each node's id, kind, label, from and counts {in, ok, failed, filtered}, drawn from the version in force.
GET/api/pipelines/<name>/logs?tail=&after={lines, next, reset}: the last tail lines (default 200, at most 1000), or the lines after byte offset after. Follow a log by passing next back as after. reset: true means the log was truncated.
GET/api/pipelines/<name>/activity?limit={events}: the newest agent-related events (escalations, resolves, applied versions), default 50, at most 500.

Each entry of GET /api/pipelines:

FieldMeaning
name, fileThe pipeline and its .pipo file.
stateSupervision state: starting, running, backoff, stopping, stopped, failed, crashed or unreachable.
statusThe runner's own status while it runs: active, paused, jammed, draining.
pid, version, socket, listen, detached, ttl, adopted, started_atThe runner process.
restarts, last_exit, next_restart_at, errorCrash restarts and the last failure ({message, hint}).
logThe log file.
lifetime{ttl, ends_at, remaining_ms}.
statsFrom the journal: in_flight, pending, delivered, filtered, dead_lettered, rejected, escalated, branched, total, oldest_pending_age_ms, throughput_per_min. null when the journal can't be read.
resourcesCPU, memory and process count of the runner.
chain{feeds, fed_by}: the pipelines it feeds and is fed by (see Chains).

Start a pipeline and wait for it:

curl -s localhost:8787/api/pipelines -H 'content-type: application/json' \
  -d "{\"file\": \"$PWD/people-intake.pipo\"}"
curl -s localhost:8787/api/pipelines/people-intake | jq '.state, .runner.stats'

A start that fails pipo check answers 422 with the diagnostics. A taken port answers 409 and names the process holding it.

Packets#

MethodPathBodyAnswer
POST/api/pipelines/<name>/push{data, source?, input?}{packet_id} once the packet is journaled. input names the input when there are several. A rejected packet is 422 with packet_id.
POST/api/pipelines/<name>/ack{packet_id}Confirms delivery of a packet waiting on delivered.check: external.
GET/api/pipelines/<name>/packets?state=&limit=&after=A page of packets, newest first. after is the previous page's next.
GET/api/pipelines/<name>/packets/<packet_id>One packet's full trace: data after each step, timings, attempts, errors, events, fan-out copies.
POST/api/pipelines/<name>/rerun{from, ids?, last?, since?, all?, current?, dry?, by?}Runs settled packets again from node from. With dry: true, only the plan. See Recovering packets.
POST/api/pipelines/<name>/resolve{ids | packet_id, action, by?, by_kind?, reason?}Settles escalated packets: action is retry, dead_letter or drop. by defaults to api, by_kind to human.
curl -s localhost:8787/api/pipelines/people-intake/push -H 'content-type: application/json' \
  -d '{"data": {"name": "Ada", "age": 36}, "source": "manual"}'
curl -s 'localhost:8787/api/pipelines/people-intake/packets?state=dead_lettered&limit=10'

Dead-letter queue#

MethodPathBodyAnswer
GET/api/pipelines/<name>/dlq?limit=&after=Dead-lettered packets with their errors.
POST/api/pipelines/<name>/dlq/replay{ids?, all?, by?}Replays each packet from the step it failed at.
POST/api/pipelines/<name>/dlq/purge{ids?, all?, by?}Deletes dead letters for good.

Give ids (a list) or all: true.

Versions and proposals#

MethodPathBodyAnswer
GET/api/pipelines/<name>/versionsNewest first, with who made each, why, author_kind, proposal, files, and the packets still pinned to each. Also current and latest.
GET/api/pipelines/<name>/versions/<v>One version with its definition and diff from the version before it.
GET/api/pipelines/<name>/diff?from=&to={diff, identical, added, removed}: a unified diff of two versions.
POST/api/pipelines/<name>/rollback{version, by?}Makes version v's definition the next version. warnings lists files that changed since.
GET/api/pipelines/<name>/proposals?state=&limit=Proposals, newest first.
GET/api/pipelines/<name>/proposals/<id>One proposal: state, problems, diff, dry-run report.
POST/api/pipelines/<name>/proposals{source, base_version, reason, by?, by_kind?, apply?}Propose a whole new source. apply: false holds it. A proposal stored as rejected is a normal answer, with its problems.
POST/api/pipelines/<name>/proposals/<id>/apply{by?}
POST/api/pipelines/<name>/proposals/<id>/reject{reason, by?}

See Live changes and proposals and Versions and rollback.

Events#

GET /events: the live stream#

Server-Sent Events of every journal event of every pipeline, plus pipeline state changes:

curl -N localhost:8787/events?pipeline=people-intake
retry: 1000

id: people-intake:1042
data: {"id":"people-intake:1042","pipeline":"people-intake","seq":1042,"at":1760083200000,"packet_id":"01J9Z…","type":"packet.delivered","node":null,"detail":{…}}

event: state
data: {"name":"people-intake","state":"running","status":"paused",…}
  • Each journal event is a data: line with id: <pipeline>:<seq>. The object has pipeline, seq (increasing per pipeline), at (epoch ms), packet_id, type (such as packet.accepted, packet.delivered, pipeline.paused), node and detail.
  • event: state carries a pipeline's new supervision state (the same object as an entry of GET /api/pipelines). It has no id.
  • A comment is sent every 15 s to keep the connection open.
  • ?pipeline=<name> limits the stream to one pipeline.

Reconnecting. Browsers' EventSource sends Last-Event-ID by itself; other clients can pass ?after=<id>. With ?pipeline=, the stream replays exactly the journal's events after that seq (up to 10 000, then an event: gap with a hint), then continues live without repeats. Without ?pipeline=, it replays from the last 1000 events the engine streamed; when the id is older, or the engine restarted, it sends event: gap with a hint. A client more than 16 MiB behind is dropped and replays when it reconnects.

An open stream keeps the engine awake: it doesn't go to sleep while a dashboard is open.

GET /api/events: a page#

GET /api/events?pipeline=&after=&limit= returns {events, more, last_id}: the last limit events (default 100, 1–1000), or those after after. Page by passing last_id back as after. With pipeline, it reads that pipeline's journal, so it works for any seq. Without it, after must still be among the engine's recent events (410 otherwise).

Ingress: /in#

Webhooks reach a pipeline's http input at /in/<pipeline><path>, where <path> is the input's with.path:

curl -X POST localhost:8787/in/people-intake/people \
  -H 'X-Pipo-Token: dev-token' -H 'content-type: application/json' -d '{"name": "Ada", "age": 36}'

The gateway forwards the request unchanged (method, path, query, raw body and headers, plus X-Forwarded-For, -Host and -Proto) to the runner's own port, and passes the runner's answer back as is. The runner answers:

AnswerWhen
202 {"packet_id": "…", "state": "accepted"}The packet is journaled (the default, respond: accepted).
200 {"packet_id": "…", "state": "delivered"}With respond: delivered, once the packet is delivered (or filtered). A packet still pending after timeout, or escalated to an agent, is a 202 with its state. A dead-lettered packet is a 502 with its error.
422 {"packet_id": "…", "state": "rejected", "error": "…", "rule": "…"}Validation failed. error is the rendered on_invalid.message; on_invalid.respond sets another status.
400The body doesn't parse as the input's format.
401The input's auth (token header or HMAC signature) failed.
503 with Retry-AfterThe pipeline's buffer is full, or it isn't running.

A csv body can hold several records; the answer is then {"packets": [...]}, one entry per record.

The gateway adds its own errors. It never retries:

AnswerWhen
404No such pipeline, or it has no http input.
503The pipeline isn't running (with Retry-After while it starts or is in crash backoff), or the connection was refused.
502A failure after the request was sent. The packet may have been accepted, so retry only with an idempotent sender.

The same route is served directly on the runner's own port (with.listen, or --listen), which keeps working while the engine is down. pipo run --listen <port> serves it without any engine. See Running pipelines.

Builder#

The dashboard's builder uses these routes. They read and write .pipo files, but never start, stop or change a running pipeline.

MethodPathBodyAnswer
GET/api/builder/catalogEvery connector with its with: JSON Schema: {workspace, inputs, taps, transforms, agents, outputs, checks, node_kinds, then}.
GET/api/builder/agents?refresh=1{agents, checked_at}: which agent providers can run on this machine, with ready, reason, hint and models. Cached 5 minutes.
GET/api/builder/files?refresh=1{workspace, files: [{file, rel, name}]}: the .pipo files under the workspace.
GET/api/builder/open?file=<abs> or ?pipeline=<name>{file, source, pipeline, diagnostics, fixtures, stubs, running}.
POST/api/builder/check{source | pipeline, base?, file?}{source, pipeline, diagnostics, ok}: a live pipo check of a draft. Always 200.
POST/api/builder/save{file, source, overwrite?}{file, created, diagnostics}. Drafts with errors are saved too.
POST/api/builder/test{source, file?, fixtures, stubs?}A dry run against 1–50 fixture packets, as pipo test, with each step's data.
GET/api/builder/schema?file=&path={file, path, exists, schema, problem}: a schema file the pipeline names.
POST/api/builder/schema{file, path, schema, overwrite?}Writes a schema file.

Files must be absolute paths to .pipo files inside the workspace, or the file of a pipeline the engine knows. Anything else is 403. Saving an existing file needs overwrite: true (409 otherwise), and sources are capped at 1 MiB (413).

Bots and settings#

MethodPathBodyAnswer
GET/api/bots{telegram: {default, bots: [{name, default, token, bot_id, allow, poll_every, api}]}}. A raw token is never returned: token is only set for a reference (op://…, env:…), and bot_id shows a raw token's public part.
POST/api/bots/telegram/<name>{token?, allow?, poll_every?, api?, default?, rename?}Add or change a bot. Keys left out keep their value; a new bot needs token.
POST/api/bots/telegram/<name>/delete{}Remove a bot. The default moves to the next one.
POST/api/bots/telegram/<name>/test{}Calls Telegram's getMe: {ok, id, username, name} or {ok: false, error}.
GET/api/settings{workspace, workspace_from, home, auto_approve}.
POST/api/settings/workspace{path, create?}Sets the builder's workspace, saved as engine.workspace in config.yaml.
POST/api/settings/agents{auto_approve}Whether pipelines agents write run without approval, saved as engine.mcp.auto_approve.
GET/api/folders?path={path, parent, folders}: a folder's sub-folders, for the folder picker.

See Telegram bots.

Agent approvals#

Pipelines agents wrote with create_pipeline, and a human's decision on them (see Agents that build pipelines).

MethodPathBodyAnswer
GET/api/approvals{auto_approve, approvals: [...]}. Each approval is {name, file, files, by, reason, hash, state, requested_at, decided_at, decided_by, note, current_hash, changed, effective, pipeline_state, contents}. effective is pending again when the files changed since they were approved, and contents lists each file with its text (up to 64 KiB).
GET/api/approvals/<name>One approval, as above.
POST/api/approvals/<name>/approve{start?, by?}Approves the files as they are now; with start: true, also starts the pipeline. {approval, pipeline}.
POST/api/approvals/<name>/reject{reason?, by?}Rejects it; the agent sees the reason. {approval}.

Starting a pipeline any other way as a human (POST /api/pipelines, …/start, …/restart) approves it too.

MCP: /mcp#

The MCP endpoint takes one JSON-RPC message per POST, authenticated with Authorization: Bearer <token> (an OAuth access token, or a static token). Its tools call the REST handlers above, so the answers match. Tokens, scopes, tools and resources are described in Agents as operators.

curl -s localhost:8787/mcp -H "Authorization: Bearer $PIPO_MCP_TOKEN" -H 'content-type: application/json' \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_status", "arguments": {"pipeline": "people-intake"}}}'

OAuth#

The MCP endpoint's OAuth server (see Agents as operators). Its errors are OAuth's {error, error_description}, not the /api shape.

MethodPathBodyAnswer
GET/.well-known/oauth-protected-resourceThe protected resource metadata (RFC 9728): {resource, authorization_servers, scopes_supported, bearer_methods_supported}. Also at …/mcp.
GET/.well-known/oauth-authorization-serverThe server metadata (RFC 8414): the endpoints, code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"].
POST/oauth/registerJSON client metadataRegisters a public client (RFC 7591). 201 {client_id, client_name, redirect_uris, …}.
GET/oauth/authorizeChecks the request (client, redirect URI, PKCE S256, resource) and redirects the browser to the consent page, /ui/#/connect/<id>.
POST/oauth/tokenform: grant_type=authorization_code or refresh_token{access_token, token_type, expires_in, refresh_token, scope}.

The consent page and the grant list use /api:

MethodPathBodyAnswer
GET/api/oauth/requests/<id>A waiting authorization request: {client_name, redirect_uri, redirect_host, scope, scopes, pipelines, suggested_name, expires_at}.
POST/api/oauth/requests/<id>/approve{scope, pipelines, name}{redirect}: where the browser goes next, with the one-time code. pipelines is null for all.
POST/api/oauth/requests/<id>/deny{}{redirect}, with error=access_denied.
GET/api/mcp/grants{grants: [{id, name, client_id, client_name, scope, pipelines, created_at, last_used_at, expires_at}], tokens: [{name, scope, pipelines}]}. tokens are the static ones from config.yaml.
POST/api/mcp/grants/<id>/revoke{}{revoked}. Its tokens stop working at once.

The runner's control socket#

Underneath the engine, each runner listens on a Unix socket, <home>/run/<name>.sock, with newline-delimited JSON requests {id, op, args}. The engine, the CLI's --no-engine mode and chains use it. It is an internal protocol: use REST or the CLI instead. The spec documents it in §7.2.

See also#