Module references
Ports — durable agents, API and MCP
Ports is the programmatic surface for durable agent runs. You POST a task to an agent by slug and get a run id back immediately; a worker executes the run independently of your request, and you reattach to a database-backed event stream to watch model calls, tool calls, human-approval pauses, and the final cited answer. Because every step is persisted, a client can disconnect, redeploy, or reconnect from a different machine without losing or restarting the run.
The same workspace is also exposed to third-party agents and IDEs through a native Model Context Protocol endpoint at /api/mcp, authenticated with the same bearer keys. MCP is a read-and-ask surface: it does not start or manage runs.
Use it standalone
Ports needs two API-key scopes and nothing else from the platform. agents:run starts, cancels, approves, and records feedback on runs; runs:read reads a run, its steps, and its stream. Both are in the default scope set for a new key, so a freshly issued key usually already has them.
Issue a key with agents:run and runs:read
A workspace key (eli_sk_…) is bound to one workspace. A user key (eli_uk_…) that can reach more than one workspace must sendX-Workspace-Idon every request, and is additionally narrowed by the owner's live workspace capabilities —agents:runalso requiresagents:executeandruns:readalso requiresagents:read, or the request is rejected 403insufficient_capability.Install the one package you need
Headless:@eli-ai/client. React surfaces:@eli-ai/react. Types only:@eli-ai/contracts/agents. Nothing in this module imports Next.js.Run an existing agent by slug
Ports executes agents; it does not author them. See the note below.
What Ports does not require
No kb:read, kb:write, data:read, data:run, or reports:* scope is involved in any of the seven run operations. The run lifecycle — queue, stream, cancel, approve, feedback, steps — is complete without a single document, entity, or data connector in the workspace. What an agent can usefully do depends on the tools its version enables, which is where the other modules enter; see How it composes.
The public API runs agents; it does not create them
/api/v1. The only public agent route is POST /api/v1/agents/{slug}/runs. Agents and their immutable config versions are authored in the eli.ai workspace application (session-guarded /api/w/{workspaceId}/agents routes) — see Creating agents. That is also why EliAgentRunner takes its agent list as a prop: no public endpoint can populate the picker for you.Keys never belong in browser code
EliProvider deliberately has no API-key prop. Ship the key server-side and point the React transport at a same-origin proxy that injects the Authorization header. The API installs no cross-origin CORS policy, so a direct browser call to https://eli.ai/api/v1 is not a supported deployment shape.React components
@eli-ai/react/agents exports two components. Both are client components, both require an EliProvider above them, and both build their own agents client from the provider's transport — they import no other capability module. EliAgents is an alias export for EliAgentRunner (the same function, re-exported under a shorter name).
EliAgentRunner
A complete run surface: a form (agent picker or free-text slug input, plus a task textarea), a Run agent submit button, a Cancel run button that appears while a run is in flight or suspended, a live-region list of run events, and an Allow / Deny panel when the run suspends on a tool that needs a human. Starting a run calls createRun then streamRun; resolving an approval calls resolveApproval and then reattaches to the stream, skipping the replayed model_call and tool_call frames it has already rendered. Cancelling aborts the local reader and calls cancelRun.
EliAgentRunner props
EliAgentOption
One inherited prop has no effect here
EliAgentRunnerProps is Omit<CommonSurfaceProps, "emptyLabel"> plus its own fields, so loadingLabel type-checks — but EliAgentRunner never reads it. The in-flight state is the button's own "Running…" text, and the empty state uses labels.empty. Use labels for copy changes.EliRunDetail
A read-only trace of one run by id, with an optional cancel action in the header. It fetches getRun once (and refetches after a successful cancel) and renders three KPI tiles — Status, Tokens (the sum of tokensIn and tokensOut), and Cost (costUsd to four decimals) — above an ordered list of steps. Each step card shows its index, its name falling back to its kind, a status badge, a formatted timestamp, and the step output as pretty-printed JSON when present. The cancel button only renders while the run status is queued, running, or suspended.
EliRunDetail props
emptyLabel is accepted but unused
EliRunDetailProps extends the full CommonSurfaceProps, so emptyLabel type-checks — but the component has no empty branch and never reads it.Headless, with your own markup
Every component is a thin shell over createAgentsClient and the two generic hooks from @eli-ai/react/hooks. If the built-in eli-* markup does not fit, use the client directly and keep the provider for transport and workspace resolution.
HTTP API
Seven operations, all bearer-authenticated with Authorization: Bearer eli_sk_… or eli_uk_…. A user key that can reach several workspaces must also send X-Workspace-Id. A run that belongs to another workspace is invisible under row-level security and returns 404 rather than 403 — the distinction is deliberately hidden.
The curl examples below use -L on non-GET calls because the apex host issues a 308 redirect, which preserves the method and body.
/api/v1/agents/{slug}/runsscope agents:runQueue a durable run of the agent's latest non-archived version and return immediately. An unknown slug, or an agent with no version, returns 404. Admission runs before the run row is created: over the workspace concurrency cap returns 429 too_many_runs with active and cap; over the monthly spend cap returns 402 spend_cap_exceeded with spentUsd, capUsd and period.
Query parameters
Request body
/api/v1/runs/{id}scope runs:readThe full run record plus every persisted step, ordered by index. costUsd is a decimal string, not a number; timestamps are ISO strings; input and output are arbitrary JSON.
/api/v1/runs/{id}/streamscope runs:readReattach to a durable run over Server-Sent Events. Persisted steps are replayed oldest-first, then the endpoint polls roughly every 700ms until it emits a terminal frame and closes. Pure database reads, so any web replica can serve it and you may open, close, and reopen it freely.
Five frame types are emitted, each as one data: line of JSON. guardrail, approval, and retry steps have no stream equivalent and are skipped during replay — read them from /runs/{id}/steps instead.
Stream lifetime and reconnects
suspended, on a terminal final (succeeded, failed, interrupted, or budget_exceeded), or on error. Two error messages come from the transport rather than the agent: run_not_found if the run disappears mid-poll, and stream_timeout at the ten-minute safety bound — the run itself keeps going, so reconnect. There is no Last-Event-ID support: every reconnect replays from the first step, so de-duplicate if you render progress. A comment line : heartbeat is sent about every 15 seconds to keep the socket warm./api/v1/runs/{id}/stepsscope runs:readThe persisted step trace only, ordered by index — the same steps as GET /runs/{id} without the run body. This is the poll surface, and the only way to see guardrail, approval, and retry steps.
/api/v1/runs/{id}/cancelscope agents:runRequest cooperative cancellation. There is no request body. cancelled: false means the run had already reached a terminal state and the transition did not apply.
/api/v1/runs/{id}/approvescope agents:runResolve a human-in-the-loop approval gate on a suspended run. The decision and the suspended → queued transition commit together, then the worker is notified; the response is the queued continuation, not the resumed answer. A run that is not suspended, an unknown approvalId, or a gate someone else already decided all return 404.
Request body
Approve returns 202 and a continuation — not the answer
202 with QueuedContinuation (runId, approvalId, status: "queued"). It does not block until the resumed run finishes and does not return text or citations. To see the outcome, reattach to GET /api/v1/runs/{id}/stream. Some published material still describes a 200 with a full run result — the route and the @eli-ai/contracts/agents type both say otherwise./api/v1/runs/{id}/feedbackscope agents:runAttach a human feedback signal to a run. Returns 201 with the new feedback row's id. The decider is attributed from the key's actor, or the key id when the key has no actor.
Request body
MCP endpoint
/api/mcp is a stateless Streamable-HTTP Model Context Protocol server. Every request builds a fresh server bound to the workspace resolved from the bearer key — the remote model never supplies the workspace. POST, GET, and DELETE are all handled by the transport per the MCP spec; POST responses are plain JSON, so clients must send Accept: application/json, text/event-stream.
/api/mcpBearer key · per-tool scopesJSON-RPC over Streamable HTTP. Eight tools — kb_search, kb_entity_lookup, kb_entity_get, kb_document_lineage, kb_graph_query, kb_data_discover, kb_data_lookup, eli_query — plus two resources, eli://workspace/summary and eli://workspace/manifest. Each tool re-checks scopes itself, so the transport is never a scope bypass.
MCP does not manage runs
agents:run is eli_query, which returns a structured cited answer synchronously; the graph and document tools accept kb:read or the legacy runs:read grant, and the two data tools require data:read / data:run. Full detail lives in MCP server.Data elements
Every table below carries a workspace_id column and a workspace row-level security policy; all reads and writes run inside a workspace-scoped transaction with FORCE RLS, which is why a cross-workspace run id is invisible rather than forbidden. Note the naming: the run table is agent_runs, even though the API path and the TypeScript contract call it a Run.
| Table | Ports reads / writes | What one row is |
|---|---|---|
| agents | read only | One named agent, addressed by slug. slug is unique per workspace; archived_at hides it. Ports resolves the slug in POST /agents/{slug}/runs and never writes here. |
| agent_versions | read only | An immutable config snapshot (version, config jsonb). A run pins exactly one, so a later edit cannot rewrite history. config holds modelSlot, systemPrompt or promptTemplateId, the tools allowlist, kbScope, budgets (maxSteps, maxTokens, maxCostUsd, maxWallClockMs) and guardrails. |
| agent_runs | read + write | One durable run. status is queued, running, suspended, idle, succeeded, failed, interrupted, or budget_exceeded; trigger is ui, api, eval, or schedule (the API sets api). Carries tokens_in, tokens_out, cost_usd (numeric, serialized as a decimal string), input, output, error, api_key_id, started_at and finished_at, plus resume_cursor and heartbeat_at for crash recovery. |
| run_steps | read + write | One step of a run, unique on (run_id, idx). kind is model_call, tool_call, mcp_call, guardrail, approval, or retry; status is ok, error, blocked, or pending. Holds input, output, model_id, per-step tokens, cost_usd and latency_ms. This is what GET /runs/{id}/steps returns and what the stream replays. |
| run_approvals | read + write | A human-in-the-loop gate: tool_name, tool_input, and status pending, allowed, or denied. The approve endpoint claims it with a conditional update, so only one reviewer can decide; note, decided_by and decided_at record the attribution. Exposed indirectly — as the approvalId on a suspended frame — not through a list endpoint. |
| feedback | write | One human signal on a run: kind, value jsonb, note, actor_id. POST /runs/{id}/feedback creates the row and returns its id; there is no public read endpoint for it in /api/v1. |
| guardrail_verdicts | written by the runner | Per-run check outcomes (check, phase pre_model or post_model, verdict pass, fail, or flag, plus details and action_taken). Visible as guardrail steps in run_steps; the verdict rows themselves have no /api/v1 read route. |
| conversations · messages | read + write when conversationId is set | Multi-turn context. Passing conversationId on a run links it to an existing thread; messages.run_id ties a persisted turn back to the run that produced it. |
| mcp_servers · mcp_tools | read only | Outbound MCP: third-party servers this workspace's agents may call, and the cache of tools discovered from them. Unrelated to the inbound /api/mcp endpoint. Registered in the workspace app, not through /api/v1. |
Two fields on the Run contract to treat carefully
costUsd is a string (Postgres numeric with six decimal places), so compare and total it as a decimal, never as a float you parse and re-serialize. evalResultId is declared on both the table and the Run contract, but no code path in this repository writes it — do not build a feature on it being populated.How it composes
Ports on its own gives you the whole run lifecycle: queue, stream, inspect steps, cancel, approve or deny a gate, and record feedback — under per-workspace concurrency and monthly spend admission, and per-agent step, token, cost, and wall-clock budgets. What it gains from the other seven modules is what the agent can reach, because an agent version's tool allowlist is what wires them in.
- Intake and Atlas — the default tool allowlist is
kb_search,kb_doc_read,kb_list_docs,kb_entity_lookup, andkb_graph_query. Those tools are always registered, so a run never fails for their absence — but against an empty corpus or an empty graph they return nothing useful, and the agent answers without evidence. This is the one pairing most agents genuinely want. - Lens — the
finalframe'scitationsarray is a list ofdocId(and optionalchunkId) pairs produced by the retrieval and answer path. Without Lens the field is still present and simply empty. The MCPeli_querytool is Lens's structured answer surfaced over MCP. - Conduit — an agent version with
dataEnabled(or akb_dataentry intools) can look up live rows through governed entity bindings. This genuinely requires Conduit: connectors and named queries must exist, and an MCP caller additionally needs thedata:read/data:runscopes. Runs work fine without it. - Warrant — the per-agent guardrail
citations.requiredand the authority and lifecycle labels returned by the graph tools come from governance. Ports enforces the run-level budgets and the approval gate on its own; certification semantics come from Warrant. - Lineage —
run_stepsis already an append-only per-run audit trail, andrun_approvalsrecords who decided what and when. Tracing a cited document back to its origin, and forward to everything it affected, is thekb_document_lineageMCP tool and needs Lineage. - Crucible — the
evaltrigger value and the fivefeedbackkinds exist so runs can be replayed against a golden set and so human signals feed the improvement loop. You can post feedback with Ports alone; interpreting it as a metric is Crucible.
Wiring an agent's tools, model slot, budgets, and approval requirements is a workspace-application task, not an API one — see Creating agents and Approvals & feedback. For the wire contract in full, read Agents and durable run streams, Authentication and scopes, and Errors.
Extending Ports
tools registers one definition that becomes both an agent-harness tool and an MCP tool, behind the same scope guard as a native one; jobs registers a pg-boss queue auto-namespaced to plugin.<id>.<name>; and httpOperations declares a REST operation (validated and permission-checked today, with the /api/v1/plugins catch-all still to be mounted). Ports emits agent.run.completed / failed / suspended. See the extension model for the contract and the trust boundary, and Build a plugin for a worked example.