Skip to documentation

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.

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.

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.

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.