Skip to documentation

Module references

Module reference / Ship it

PortsProgrammatic surfaces

Ports

Durable agents, a scoped REST API, and a native MCP server expose the governed platform to people and machines.
  • Lens

    Expose grounded search and answer capabilities.

  • Conduit

    Expose entitled live-data tools and results.

  • Warrant

    Apply scopes, approvals, and human-in-the-loop policy.

  • Lineage

    Trace every run, tool call, model call, and decision.

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.

Ports — run lifecycle

The durable contract: 202 with a run id, execution on the worker, replay-and-tail over SSE. Approvals close the stream — resolve the gate, then reattach.

Rendering diagram

Downloads

Concepts

  • Run lifecycle
  • Modules
  • Ports

Keywords

  • model_call / tool_call
  • claim run, persist run_steps
  • Your app
  • GET /runs/{id}/stream
  • replay steps, then poll
  • POST /runs/{id}/approve
  • POST /agents/{slug}/runs
  • admit + persist queued run
  • suspended (approvalId) · stream closes
  • GET /runs/{id}/stream (reattach)
  • final { status, text, citations }
  • Postgres
  • eli.ai
  • /api/v1
  • Worker
Source and generation provenance

Status: current

Generated at: 2026-08-17T18:36:07.602Z

Source hash: 557105badae2e01d5307ed1d1ace15ed87dcc2575e8fe842e2e6763210a6390e

Metadata payload hash: 61dff96855e843b2378a912eaeaa8770f15b5450c6f0312abfa25fe92c7ce113

Canonical appearance

src/app/(docs)/docs/modules/ports/page.tsx:47 route /docs/modules/ports

All appearances

  • canonicalsrc/app/(docs)/docs/modules/ports/page.tsx:47 route /docs/modules/ports

No mirrored appearances.

Generation versions

App: eli-ai 0.1.0

Mermaid: 11.16.0 · Mermaid CLI: 11.16.0

Node: v26.3.1 · Yarn: 4.17.1

Renderer config hash: 68c10966fe84406ee626034d58bfabd555df9f65f691204b7c46db24038da101

Renderer theme hash: c80287a78d80ad63d27bd5ca348b2ef9a7e2f44da289e436be6484ea28a1b033

Adapter versions: diagramGenerator=2, drawioFlowchart=1, drawioGantt=1, drawioSequence=1, drawioState=1

Full sidecar JSON: ports-run-lifecycle-557105ba.json

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.

  1. 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 send X-Workspace-Id on every request, and is additionally narrowed by the owner's live workspace capabilities — agents:run also requires agents:execute and runs:read also requires agents:read, or the request is rejected 403 insufficient_capability.
  2. 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.
  3. Run an existing agent by slug

    Ports executes agents; it does not author them. See the note below.
installbash
npm install @eli-ai/client @eli-ai/contracts
the whole module, standalonets
import { createAgentsClient } from "@eli-ai/client/agents";
import { createEliTransport } from "@eli-ai/client/core";
import type { RunEvent } from "@eli-ai/contracts/agents";

const agents = createAgentsClient(
  createEliTransport({
    baseUrl: "https://eli.ai",
    apiKey: () => process.env.ELI_API_KEY!,
    // Only needed for a user key that can reach several workspaces.
    workspaceId: process.env.ELI_WORKSPACE_ID,
  }),
);

const queued = await agents.createRun("research-assistant", {
  input: "Summarize the open incidents and cite the runbook.",
});
console.log(queued.runId, queued.status, queued.stream);

async function follow(runId: string): Promise<RunEvent | null> {
  for await (const event of agents.streamRun(runId)) {
    if (event.type === "model_call" || event.type === "tool_call") continue;
    return event; // suspended | final | error — the server closes after these
  }
  return null;
}

let terminal = await follow(queued.runId);

// A suspended run is parked on a human decision. Resolve it, then reattach:
// the stream replays from the beginning, so de-duplicate if you render progress.
while (terminal?.type === "suspended") {
  await agents.resolveApproval(queued.runId, {
    approvalId: terminal.approvalId,
    decision: "allow",
  });
  terminal = await follow(queued.runId);
}

if (terminal?.type === "final") {
  console.log(terminal.status, terminal.text, terminal.citations);
}

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

There is no create-agent, list-agents, or edit-agent operation under /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

agentsEliAgentOption[]Options for the agent picker. Defaults to an empty array, in which case the component renders a free-text slug input (placeholder "research-assistant") instead of a select.
initialAgentSlugstringSlug selected on mount. Defaults to "", then falls back to agents[0].slug when a list is supplied.
initialInputstringInitial task text in the textarea. Defaults to "".
canApprovebooleanRender the Allow / Deny panel when a run suspends. Defaults to true; false hides the controls and the run stays suspended until something else resolves it.
labelsPartial<EliAgentRunnerLabels>Per-string overrides merged over the defaults (title, description, agent, input, run, cancel, events, allow, deny, empty).
titlestringSurface heading. Defaults to labels.title.
descriptionstringSurface sub-heading. Defaults to labels.description.
classNamestringAppended to the root element's built-in eli-root class.
errorLabelstringMessage shown when the run mutation rejects. Defaults to "The agent run could not be completed."
onError(error: unknown) => voidCalled when the run, cancel, or approval mutation rejects.
onRunQueued(runId: string) => voidCalled with the durable run id as soon as createRun resolves, before streaming starts.
onRunEvent(event: RunEvent) => voidCalled for every event the component keeps, in stream order, after replay de-duplication.
onRunComplete(events: RunEvent[]) => voidCalled with the full collected event list once it contains a final or error frame. A run that ends suspended does not fire this.
onApprovalResolved(result: QueuedContinuation) => voidCalled after the approve/deny call is accepted, immediately before the component reattaches to the stream.

EliAgentOption

slugrequiredstringThe agent slug submitted to createRun.
labelrequiredstringText rendered in the select option.
descriptionstringAccepted by the type but not rendered by the current select markup. Safe to pass; it has no visual effect.

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.
provider wiring + EliAgentRunnertsx
"use client";

import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliAgentRunner } from "@eli-ai/react/agents";
import "@eli-ai/react/styles.css";

// Same-origin route that attaches the Bearer key server-side.
const transport = createEliTransport({
  fetch: (input, init) => fetch(input, { ...init, credentials: "same-origin" }),
});

export function AgentConsole({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliAgentRunner
        agents={[
          { slug: "research-assistant", label: "Research assistant" },
          { slug: "incident-reviewer", label: "Incident reviewer" },
        ]}
        initialAgentSlug="research-assistant"
        labels={{ run: "Start research" }}
        onRunQueued={(runId) => console.log("queued", runId)}
        onRunComplete={(events) => console.log("done", events.length)}
      />
    </EliProvider>
  );
}

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

runIdrequiredstringThe durable run id to load.
canCancelbooleanRender the Cancel run action when the run is still cancellable. Defaults to true.
titlestringSurface heading. Defaults to "Run detail".
descriptionstringSurface sub-heading. Defaults to "Trace " followed by the runId.
classNamestringAppended to the root element's built-in eli-root class.
loadingLabelstringText beside the spinner while loading. Defaults to "Loading run…".
errorLabelstringMessage shown with a retry button when the fetch fails. Defaults to "The run could not be loaded."
onError(error: unknown) => voidCalled when the run query or the cancel mutation rejects.
onCancelled(result: CancelRunResult) => voidCalled after cancelRun resolves. Inspect result.cancelled — false means the run was already terminal.

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.
EliRunDetailtsx
"use client";

import { EliProvider } from "@eli-ai/react/provider";
import { EliRunDetail } from "@eli-ai/react/agents";

export function RunTrace({
  transport,
  workspaceId,
  runId,
}: {
  transport: Parameters<typeof EliProvider>[0]["transport"];
  workspaceId: string;
  runId: string;
}) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliRunDetail
        runId={runId}
        canCancel={false}
        title="Audit trace"
        onError={(error) => console.error(error)}
      />
    </EliProvider>
  );
}

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.

useEliQuery + createAgentsClienttsx
"use client";

import { createAgentsClient } from "@eli-ai/client/agents";
import { useEliQuery } from "@eli-ai/react/hooks";
import type { RunSteps } from "@eli-ai/contracts/agents";

export function StepCount({ runId }: { runId: string }) {
  const steps = useEliQuery<RunSteps>(`run.steps:${runId}`, (context, signal) =>
    createAgentsClient(context.transport).listRunSteps(runId, {
      signal,
      ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
    }),
  );

  if (steps.isLoading) return <p>Loading…</p>;
  return <p>{steps.data?.steps.length ?? 0} steps</p>;
}

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.

POST/api/v1/agents/{slug}/runsscope agents:run

Queue 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

stream"inline"Compatibility mode only. Executes the loop inside this request and returns an SSE stream instead of a 202. Omit it for the durable contract.

Request body

inputrequiredstringThe task or question for the agent. Must be at least one character.
conversationIdstringContinue an existing conversation for multi-turn context. Omit to start fresh.
Example requestbash
curl -L -X POST https://eli.ai/api/v1/agents/research-assistant/runs \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input":"Summarize the open incidents and cite the runbook."}'
Responsejson
{
  "runId": "01KY10EEEEEEEEEEEEEEEEEEEE",
  "status": "queued",
  "stream": "/api/v1/runs/01KY10EEEEEEEEEEEEEEEEEEEE/stream"
}
GET/api/v1/runs/{id}scope runs:read

The 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.

Example requestbash
curl https://eli.ai/api/v1/runs/$RUN_ID \
  -H "Authorization: Bearer $ELI_API_KEY"
Responsejson
{
  "run": {
    "id": "01KY10EEEEEEEEEEEEEEEEEEEE",
    "workspaceId": "01KY10BBBBBBBBBBBBBBBBBBBB",
    "agentVersionId": "01KY10FFFFFFFFFFFFFFFFFFFF",
    "conversationId": null,
    "status": "succeeded",
    "trigger": "api",
    "input": { "input": "Summarize the open incidents." },
    "output": { "text": "Three incidents are open…" },
    "error": null,
    "tokensIn": 5120,
    "tokensOut": 640,
    "costUsd": "0.031200",
    "startedAt": "2026-07-21T09:14:00.000Z",
    "finishedAt": "2026-07-21T09:14:21.000Z"
  },
  "steps": [
    {
      "id": "01KY10GGGGGGGGGGGGGGGGGGGG",
      "runId": "01KY10EEEEEEEEEEEEEEEEEEEE",
      "idx": 0,
      "kind": "model_call",
      "name": null,
      "modelId": "claude-sonnet-4-5",
      "tokensIn": 5120,
      "tokensOut": 640,
      "costUsd": "0.031200",
      "latencyMs": 2100,
      "status": "ok",
      "createdAt": "2026-07-21T09:14:02.000Z"
    }
  ]
}
GET/api/v1/runs/{id}/streamscope runs:read

Reattach 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.

Example requestbash
curl -N https://eli.ai/api/v1/runs/$RUN_ID/stream \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "Accept: text/event-stream"

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.

SSE frames (one JSON object per data: line)text
data: {"type":"model_call","runId":"01KY10E…","textLength":512,"toolCalls":1}

data: {"type":"tool_call","runId":"01KY10E…","name":"kb_search","ok":true}

: heartbeat

data: {"type":"suspended","runId":"01KY10E…","approvalId":"01KY10H…","toolName":"kb_propose_note"}

data: {"type":"final","runId":"01KY10E…","status":"succeeded","text":"Three incidents are open…","citations":[{"docId":"01KY10A…"}]}

data: {"type":"error","message":"stream_timeout"}

Stream lifetime and reconnects

The stream closes on 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.
GET/api/v1/runs/{id}/stepsscope runs:read

The 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.

Example requestbash
curl https://eli.ai/api/v1/runs/$RUN_ID/steps \
  -H "Authorization: Bearer $ELI_API_KEY"
Responsejson
{
  "steps": [
    {
      "id": "01KY10GGGGGGGGGGGGGGGGGGGG",
      "runId": "01KY10EEEEEEEEEEEEEEEEEEEE",
      "idx": 0,
      "kind": "tool_call",
      "name": "kb_search",
      "tokensIn": 0,
      "tokensOut": 0,
      "costUsd": "0",
      "latencyMs": 84,
      "status": "ok",
      "createdAt": "2026-07-21T09:14:02.000Z"
    }
  ]
}
POST/api/v1/runs/{id}/cancelscope agents:run

Request cooperative cancellation. There is no request body. cancelled: false means the run had already reached a terminal state and the transition did not apply.

Example requestbash
curl -L -X POST https://eli.ai/api/v1/runs/$RUN_ID/cancel \
  -H "Authorization: Bearer $ELI_API_KEY"
Responsejson
{ "cancelled": true }
POST/api/v1/runs/{id}/approvescope agents:run

Resolve 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

approvalIdrequiredstringThe id from the suspended SSE frame or from run_approvals. Must be at least one character.
decisionrequired"allow" | "deny"Allow lets the parked tool call proceed; deny resumes the run without it.
notestringOptional free text recorded on the approval row alongside the decider and timestamp.
Example requestbash
curl -L -X POST https://eli.ai/api/v1/runs/$RUN_ID/approve \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"approvalId":"01KY10HHHHHHHHHHHHHHHHHHHH","decision":"allow","note":"Reviewed"}'
Responsejson
{
  "runId": "01KY10EEEEEEEEEEEEEEEEEEEE",
  "approvalId": "01KY10HHHHHHHHHHHHHHHHHHHH",
  "status": "queued"
}

Approve returns 202 and a continuation — not the answer

This route responds 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.
POST/api/v1/runs/{id}/feedbackscope agents:run

Attach 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

kindrequired"thumb" | "rating" | "correction" | "edit" | "citation_flag"The signal type. Any other value is rejected 400.
valueunknownKind-specific payload stored as JSON — no shape is enforced by the route. Examples in the OpenAPI document use "up" for a thumb and 4 for a rating.
notestringOptional free-text comment.
Example requestbash
curl -L -X POST https://eli.ai/api/v1/runs/$RUN_ID/feedback \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"thumb","value":"up","note":"Right runbook, right count."}'
Responsejson
{ "id": "01KY10JJJJJJJJJJJJJJJJJJJJ" }

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.

POST/api/mcpBearer key · per-tool scopes

JSON-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.

Example requestbash
curl -L -X POST https://eli.ai/api/mcp \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

MCP does not manage runs

The MCP surface is read-and-ask only. There is no start-run, cancel-run, approve, or stream tool — a remote agent that needs the durable run lifecycle uses the seven HTTP operations above. The one MCP tool that requires 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.

TablePorts reads / writesWhat one row is
agentsread onlyOne 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_versionsread onlyAn 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_runsread + writeOne 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_stepsread + writeOne 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_approvalsread + writeA 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.
feedbackwriteOne 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_verdictswritten by the runnerPer-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 · messagesread + write when conversationId is setMulti-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_toolsread onlyOutbound 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.

Feature map

Six feature areas cover the module: how a run is admitted and made durable, what bounds it, how a human enters the loop, how a client watches it, what an agent is allowed to reach, and how the same workspace is served to external harnesses over MCP.

Feature areaWhat it providesDelivered bySurfaces
Durable run lifecycleA 202-and-run-id contract: the run executes on a worker, every step persists, and execution survives client disconnects, redeploys, and process restarts.Agent runner, durable executor and job queue, the agent_runs and run_steps tables.POST /api/v1/agents/{slug}/runs, GET /api/v1/runs/{id}, EliAgentRunner.
Admission and budgetsPer-workspace concurrency and monthly spend gates before a run exists, plus per-agent step, token, cost, and wall-clock budgets enforced while it runs.Admission control service; budget fields on the immutable agent config.429 too_many_runs and 402 spend_cap_exceeded on the run route; budget_exceeded terminal status.
Human-in-the-loop approvalsA tool call can park the run on a pending gate; one reviewer decides allow or deny and the run resumes from persisted state.Approval gate in the runner, continuation reconciliation, the run_approvals table.suspended stream frames, POST /api/v1/runs/{id}/approve, EliAgentRunner allow/deny panel.
Streaming and reattachServer-sent events that replay persisted steps oldest-first and then poll — open, close, and reopen freely from any replica.Reattach SSE tail reading the run_steps and agent_runs tables.GET /api/v1/runs/{id}/stream, streamRun on the SDK client.
Agent versions and capability locksImmutable config versions — model slot, tool allowlist, budgets, guardrails — plus a capability lock that binds the agent to named platform areas.Agent config schema, capability-lock module, the agents and agent_versions tables.Every run pins one version; the lock projects onto REST tools and MCP mounts alike.
Native MCP surfaceA stateless streamable-HTTP MCP server over the same bearer keys, plus per-agent mounts that expose exactly one agent's locked toolset to external harnesses.MCP server builder and the agent-mount route; tool closures re-check scopes per call.POST /api/mcp and /api/mcp/agents/{slug}.

System architecture

The web process and the worker are decoupled by the database. Routes admit, enqueue, read, and resolve; the worker owns the model loop; both sides meet only in the workspace tables. The MCP endpoint is a parallel front door onto the same governed tools, never a second execution engine.

Ports — system architecture

Rectangles are API routes and services, the rounded node is the chat model, and cylinders are workspace tables under row-level security. The reattach tail reads only the database, which is why any web replica can serve a stream.

Rendering diagram

Downloads

Concepts

  • System architecture
  • Public surface
  • Worker loop
  • Run control (web process)
  • Modules
  • Ports

Keywords

  • POST {slug}/runs (API)
  • POST + {slug} (API)
  • GET {id} + /steps + /stream (API)
  • POST {id}/cancel + /approve + /feedback (API)
  • Reattach SSE tail (service)
  • Native MCP server (service)
  • Agent runner (service)
  • Tool registry (service)
  • Approval gate (service)
  • model: chat
  • Admission control (service)
  • Durable executor + job queue (service)
  • Capability lock (service)
  • /api/v1/agents
  • /api/v1/runs
  • /api/mcp
  • /api/mcp/agents
  • slug
  • id
  • store
Source and generation provenance

Status: current

Generated at: 2026-08-17T18:36:09.695Z

Source hash: 5a58d8c0f7f92963663aa62883702e1d0cbb699730a1f3ae52508e35d94cde88

Metadata payload hash: eb5c5ec1042fad7e8cb2db347033cebbbddcaf5df19cf3795cfcf95fb927907f

Canonical appearance

src/app/(docs)/docs/modules/ports/page.tsx:69 route /docs/modules/ports

All appearances

  • canonicalsrc/app/(docs)/docs/modules/ports/page.tsx:69 route /docs/modules/ports

No mirrored appearances.

Generation versions

App: eli-ai 0.1.0

Mermaid: 11.16.0 · Mermaid CLI: 11.16.0

Node: v26.3.1 · Yarn: 4.17.1

Renderer config hash: 68c10966fe84406ee626034d58bfabd555df9f65f691204b7c46db24038da101

Renderer theme hash: c80287a78d80ad63d27bd5ca348b2ef9a7e2f44da289e436be6484ea28a1b033

Adapter versions: diagramGenerator=2, drawioFlowchart=1, drawioGantt=1, drawioSequence=1, drawioState=1

Full sidecar JSON: ports-system-architecture-5a58d8c0.json

How to read the Ports architecture

  1. Follow a run left to right: The run route admits, the durable executor commits the queued row and enqueues the job, and the worker-side runner drives the model loop.
  2. See what the runner consults: It pins the agent version, filters the assembled toolset through the capability lock, and persists every step, verdict, and approval as it advances.
  3. Note the read-only tail: Streaming never touches worker memory: the SSE tail replays run_steps and polls run status straight from the store.
  4. Treat MCP as a projection: The native MCP server and the per-agent mounts register the same governed tools behind the same scope checks — reads and asks, not run management.
Trust boundary
Admission, scopes, capability locks, and per-call tool scope re-checks all sit between a caller and any model or tool execution; the MCP transport is never a scope bypass.
Durable state
agent_runs carries status, budgets, output, and the resume cursor; run_steps is the replayable trace; run_approvals records who decided what; agents and agent_versions pin immutable configuration.

Failure paths

  • Concurrency cap breach rejects with 429 before a row exists
  • Monthly spend cap breach rejects with 402
  • Budget exhaustion ends the run as budget_exceeded
  • Worker crash resumes from the persisted cursor, not from memory

Signals

  • Non-terminal run count against the concurrency cap
  • Per-run tokens, cost, and step counts
  • Suspension frequency and approval latency
  • Stream reconnect and replay rates

Where the logic lives

The code map locates each component. Table names stay in the notes; all are defined in the schema module with workspace row-level security.

ComponentKindLives atNotes
Run creation routeAPIsrc/app/api/v1/agentscreateAgentRun under the slug's runs path — admission first, then the queued row, then 202 with the stream URL.
Run read + action routesAPIsrc/app/api/v1/runsgetRun, streamRun, listRunSteps, cancelRun, resolveRunApproval, and submitRunFeedback — the other six of the seven operations.
Native MCP endpointAPIsrc/app/api/mcpStreamable-HTTP transport building a fresh per-request server bound to the key's workspace; agent-scoped mounts live under its agents segment.
Agent runnerservicesrc/server/agents/runner.tsThe hand-rolled single-step loop: one generation per iteration, manual tool execution, budget accounting, guardrails, and the persisted resume cursor.
Tool registryservicesrc/server/agents/registry.tsDefines the built-in capability tools and assembles the per-run ToolSet; a tool that needs a human raises the suspend signal the runner turns into an approval.
Capability lockservicesrc/server/agents/capability-lock.tsEight named areas binding an agent to platform capabilities, enforced at publish time, run time, and on the MCP mount.
Admission controlservicesrc/server/agents/admission.tsConcurrency cap over non-terminal runs and the monthly spend cap, reported as 429 and 402 with their payload fields.
Durable start + continuationservicesrc/server/agents/starts.tsThe outbox handoff: the queued agent_runs row commits before the job sends, so a broker failure is recoverable. Decided approvals reconcile through src/server/agents/continuations.ts.
Reattach streamservicesrc/server/agents/reattach.tsReplay-then-poll SSE over run_steps and run status, with heartbeats and a bounded stream lifetime.
Agent config + versionsservicesrc/server/agents/config.tsThe config schema (model slot, tools, kbScope, budgets, guardrails) and version resolution — a run pins exactly one immutable agent_versions row.
MCP server buildservicesrc/server/mcp/server.tsRegisters the tool and resource surface; every tool closure injects the key-resolved workspace and re-checks its own scopes.
Table definitionsstoresrc/server/db/schema.tsagents, agent_versions, agent_runs, run_steps, run_approvals, feedback, guardrail_verdicts, conversations, messages, mcp_servers, mcp_tools.
React surfacesUIpackages/react/src/agents/index.tsxEliAgentRunner (with the EliAgents alias) and EliRunDetail.
Headless client + contractsSDKpackages/client/src/agents.ts · packages/contracts/src/agents.tscreateAgentsClient — createRun, streamRun, getRun, listRunSteps, cancelRun, resolveApproval — and the RunEvent frame types.

Primary runtime flow

The highest-value path is one durable run end to end: createAgentRun through admission and the queue, the worker loop stepping the model under its locked toolset, a human gate in the middle, and streamRun replaying the trace for whoever is watching.

Ports — primary runtime flow

One durable run. The web process owns admission, the queued row, and the stream tail; the worker owns the model loop; the approval decision and the suspended-to-queued transition commit together before the worker is notified.

Rendering diagram

Downloads

Concepts

  • Primary runtime flow
  • Modules
  • Ports

Keywords

  • agent-run routes (API)
  • GET {id}/stream (API)
  • Durable executor + pg-boss queue (service)
  • agent_runs + run_steps + run_approvals (store)
  • commit queued agent_runs row, then enqueue job
  • append run_steps, update budgets
  • pending run_approvals row, status suspended
  • Chat model (model)
  • worker claims the run
  • text or tool calls
  • Your app (agents:run / runs:read)
  • Admission control (service)
  • Agent runner (service, worker)
  • Tool registry under capability lock (service)
  • createAgentRun — input, optional conversationId
  • concurrency + monthly spend checks
  • admitted (else 429 or 402)
  • single-step generation with the locked toolset
  • execute tool under scope + lock checks
  • streamRun — replay persisted steps, then poll
  • suspended frame, stream closes
  • resolveRunApproval — allow or deny
  • decision + suspended-to-queued commit together
  • resume from the persisted cursor
  • terminal status + output
  • final frame with text and citations
  • /api/v1
  • /api/v1/runs
Source and generation provenance

Status: current

Generated at: 2026-08-17T18:36:02.850Z

Source hash: 3a39f92a07b0bccbfc5c8661732c7474c7e0ad6586907e8289e4ee28b2806830

Metadata payload hash: c7e71c15a76cfd0b53f985f537b0f44b81c922b23f08c4a585da34d601f2fd5d

Canonical appearance

src/app/(docs)/docs/modules/ports/page.tsx:124 route /docs/modules/ports

All appearances

  • canonicalsrc/app/(docs)/docs/modules/ports/page.tsx:124 route /docs/modules/ports

No mirrored appearances.

Generation versions

App: eli-ai 0.1.0

Mermaid: 11.16.0 · Mermaid CLI: 11.16.0

Node: v26.3.1 · Yarn: 4.17.1

Renderer config hash: 68c10966fe84406ee626034d58bfabd555df9f65f691204b7c46db24038da101

Renderer theme hash: c80287a78d80ad63d27bd5ca348b2ef9a7e2f44da289e436be6484ea28a1b033

Adapter versions: diagramGenerator=2, drawioFlowchart=1, drawioGantt=1, drawioSequence=1, drawioState=1

Full sidecar JSON: ports-primary-runtime-flow-3a39f92a.json

How to read the run flow

  1. Admission happens before existence: Concurrency and spend checks run before the run row is created, so a rejected request leaves nothing behind and a run never counts against its own cap.
  2. The loop is single-step on purpose: Each iteration is one bounded generation with definition-only tools; the runner executes tool calls itself and persists the step before advancing.
  3. Suspension is a persisted state: A gated tool writes a pending approval and parks the run; the stream closes on the suspended frame and the decision arrives over the approve operation.
  4. Watching is decoupled from executing: streamRun replays persisted steps and polls — reconnect at any time, from any replica, and de-duplicate replayed frames client-side.
Trust boundary
Every tool execution passes the capability lock and per-tool scope checks inside the worker; approvals are claimed with a conditional update so exactly one reviewer decides a gate.
Durable state
The resume cursor, step trace, approval decisions, budgets, and final output all live in Postgres — a run resumes from the database alone after hours or a restart.

Failure paths

  • Suspended runs wait indefinitely until a decision arrives
  • Deny resumes the run without the parked tool call
  • Ten-minute stream bound closes the socket while the run continues
  • Cancellation is cooperative and no-ops on terminal runs

Signals

  • Queue latency from queued to running
  • Steps, tokens, and cost per run against budgets
  • Approval decision latency and deny rate
  • Terminal status distribution across runs

Internals and invariants

Durable run lifecycle

The loop is hand-rolled around single-step generation: the harness owns the messages array, persists a resume cursor and every step as it advances, and treats the database as the only source of truth. That is what lets a run survive hours-long suspensions, process restarts, and worker crashes without replaying model calls.

  • Invariant — a run can resume from Postgres alone: everything needed to continue — system prompt, messages, step index, budgets — persists as the loop advances, and no resume path reads worker memory. Enforced in src/server/agents/runner.ts.
  • Invariant — the queued run row commits before the job is sent, so a crash between commit and send is reconstructed from tenant state instead of losing the run. Enforced in src/server/agents/starts.ts.

Admission and budgets

Admission is checked in the web process before the executor pre-creates the run row: first the per-workspace cap on non-terminal runs, then the monthly spend cap. Once running, the per-agent budgets from the pinned config bound steps, tokens, cost, and wall-clock, and exhaustion is a terminal status, not an error.

  • Invariant — admission precedes row creation, so a run never counts against its own concurrency cap and a rejected request creates no state. Enforced in src/server/agents/admission.ts.

Human-in-the-loop approvals

A tool that requires review raises a suspend signal from inside the loop; the runner writes the pending gate with the tool name and input, flips the run to suspended, and stops. The decision and the suspended-to-queued transition commit together, and the continuation carries the decided approval back into the loop.

  • Invariant — a gate is decided exactly once: the approve path claims the pending row with a conditional update, so a second reviewer sees a 404 rather than a double decision. Enforced by the approve handler in src/app/api/v1/runs with queued continuations reconciled in src/server/agents/continuations.ts.

Streaming and reattach

The stream is a database tail, not a worker attachment: persisted steps replay oldest-first mapped to the same frame types the interactive path emits, then the tail polls for new steps and the run's status, closing on suspended or terminal frames.

  • Invariant — the tail performs pure reads, which is why any web replica can serve it and why closing and reopening a stream can never affect the run. Enforced in src/server/agents/reattach.ts.

Agent versions and capability locks

Configuration is immutable-by-version: publishing appends a new version, in-flight runs keep the one they pinned, and the capability lock names the platform areas the agent may reach. The lock is enforced three times — a version outside its own lock cannot be saved, the runner filters the assembled toolset at run time, and the agent's MCP mount projects the same lock outward.

  • Invariant— the lock never widens: a legacy version predating its agent's lock degrades to the locked subset at run time rather than escaping it. Enforced in src/server/agents/capability-lock.ts and applied in src/server/agents/runner.ts.
  • Invariant — a run pins exactly one immutable version, so editing an agent never rewrites the config a historical run executed under. Enforced by version resolution in src/server/agents/config.ts.

Native MCP surface

Every MCP request builds a fresh server bound to the workspace resolved from the bearer key. Each tool closure re-checks its own scopes before doing work, mirroring the REST scope map, and the per-agent mounts register only the tools the agent's locked toolset maps to — resolved per request, so publishing a new version takes effect without remounting.

  • Invariant — the remote model never supplies workspace scope, and the transport is never a scope bypass: workspace binding happens at server construction and scopes re-check per tool call. Enforced in src/server/mcp/server.ts.

For a self-hosting team the supported extension points are the tool and job seams: a plugin tool registers once and appears as both an agent-harness tool and an MCP tool behind the same scope guard, background jobs get namespaced queues, and custom run consoles build cleanly on the headless agents client and the frame types. Admission, budgets, the capability lock, and the durable suspend/resume contract are host-owned and not designed to be replaced.

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, and kb_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 final frame's citations array is a list of docId (and optional chunkId) pairs produced by the retrieval and answer path. Without Lens the field is still present and simply empty. The MCP eli_query tool is Lens's structured answer surfaced over MCP.
  • Conduit — an agent version with dataEnabled (or a kb_data entry in tools) 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 the data:read / data:run scopes. Runs work fine without it.
  • Warrant — the per-agent guardrail citations.required and 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_steps is already an append-only per-run audit trail, and run_approvals records who decided what and when. Tracing a cited document back to its origin, and forward to everything it affected, is the kb_document_lineage MCP tool and needs Lineage.
  • Crucible — the eval trigger value and the five feedback kinds 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

Ports is where most plugins surface. 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.