openapi: 3.1.0 info: title: Bindu Gateway API version: "1.0.0" summary: External HTTP surface of the Bindu Gateway — a task-first orchestrator that plans over a caller-supplied catalog of A2A agents. description: | # Bindu Gateway API The **Bindu Gateway** sits between an external system (your app, a custom frontend, another service) and one or more **Bindu A2A agents**. It takes a user question + an agent catalog and returns a streaming plan: the gateway's planner LLM decomposes the request, invokes A2A agents via the polling protocol, and emits Server-Sent Events in real time. Distinct from the per-agent **Bindu Agent API** (see the repo-root `openapi.yaml`), which describes what a single `bindufy()`-built agent exposes. This spec documents the **gateway** — the orchestrator sitting one layer up. --- ## Mental model: one endpoint, many turns Every orchestration goes through `POST /plan`. Inside, the planner LLM runs an agentic loop — it calls A2A agents as tools, the results feed back into the LLM, and the loop continues up to `max_steps` or until the plan resolves. Two auxiliary endpoints support health probing and DID-based peer authentication: | Path | Purpose | |---|---| | `POST /plan` | Open a new plan or resume an existing session. Streams SSE. | | `GET /health` | Liveness + cheap config probe. | | `GET /.well-known/did.json` | The gateway's own DID document (only when a DID identity is configured via env). | --- ## Request shape A `/plan` request carries three things: 1. **`question`** — the user's natural-language input. 2. **`agents[]`** — the catalog of A2A peers the planner may call, each with an endpoint, authentication descriptor, and list of skills. The gateway does **not** host agents; the caller is always the source of truth for "what can we reach." 3. **`preferences`** and **`session_id`** (both optional) — caps and continuation handles. The shape is stable and additive; unknown top-level keys are accepted (forward-compatible `.passthrough()`), but `preferences` keys are strict snake_case. Clients sending camelCase preferences will have them silently dropped — match the schema below. --- ## Response shape — Server-Sent Events The happy path returns `200 OK` with `Content-Type: text/event-stream`. Errors surface in three ways depending on when they occur: - **Before streaming starts** (auth failure, invalid JSON, malformed request, session creation failure): `401`/`400`/`500` with a JSON `{ error, detail? }` body. - **During streaming** (planner or tool failure): a single `event: error` SSE frame, followed by `event: done`. - **Never silent** — every successful plan closes with `event: done` (empty payload). Consumers should treat the absence of `done` as an incomplete stream. SSE events emitted during a plan, in typical order: | Event | When | Purpose | |---|---|---| | `session` | Once, before the plan starts | Carries session identifiers so clients can correlate. | | `plan` | Once, when the planner starts its first turn | Announces plan_id. | | `text.delta` | Many (streaming planner output) | Incremental text chunks for the final assistant message. | | `task.started` | Per A2A tool call | The planner decided to call a peer agent. | | `task.artifact` | Per A2A tool call | The peer returned an artifact, wrapped in a `` envelope. | | `task.finished` | Per A2A tool call | Terminal state of the peer call. | | `compaction-summary` | Zero or one per call (mid-stream) | New compaction summary produced when history overflowed the context window. Client must persist and ship back as `prior_summary` on the next `/plan` call. | | `final` | Once, at the end | Stop reason + usage counters. | | `error` | Only on failure during streaming | Human-readable message. | | `done` | Always last | Empty marker so clients can close cleanly. | --- ## Recipes (internal) The gateway supports **progressive-disclosure recipes** — markdown playbooks the planner lazy-loads when a task matches (e.g., "multi-agent research", "payment-required flow"). Recipes are operator- authored and not part of this HTTP API surface: they live in `gateway/recipes/` and are injected automatically into the planner's system prompt as metadata, with the body fetched on demand via an internal `load_recipe` tool. You cannot upload, list, or invoke recipes via the HTTP API; they influence the planner's behavior transparently. See the gateway README §Recipes for authoring details. --- ## A2A protocol pass-through The gateway speaks A2A (JSON-RPC 2.0 over HTTP) to every peer in `agents[]` — `message/send` + `tasks/get` polling, with DID signature verification when configured. A2A task states (`submitted`, `working`, `input-required`, `auth-required`, `payment-required`, `completed`, `failed`, `canceled`) flow through to the planner; terminal states become `task.finished` events, non-terminal states can surface as planner text or trigger recipe-based handling (e.g., surfacing a `payment-required` URL to the user). See the Bindu Agent API spec (`openapi.yaml` at the repo root) for the full A2A protocol surface. contact: name: Bindu Team url: https://docs.getbindu.com/ license: name: Apache-2.0 servers: - url: http://localhost:3774 description: Local development (default port) - url: https://gateway.example.com description: Production deployment (replace with your host) tags: - name: Plan description: | Open a new plan or resume an existing session. Server-Sent Events stream back the planner's turn-by-turn output, tool calls, and final answer. - name: Health description: Liveness and basic configuration probes. - name: Identity description: | The gateway's self-published DID document, for A2A peers that need to verify `did_signed` outbound calls. Only exposed when the gateway has a DID identity configured via env. paths: /plan: post: tags: [Plan] operationId: postPlan summary: Open a plan; stream SSE of the orchestration. description: | Accepts a user question + agent catalog, starts (or resumes) a session, and streams Server-Sent Events as the planner runs. ### Session continuation (stateless model — Path A) The gateway no longer owns durability. Each `/plan` call is its own ephemeral session: pass prior turns in `history` and the latest compaction summary (if you have one) in `prior_summary`. The client is the canonical record; the gateway is pure compute. `session_id` is now just a correlation tag echoed back on the first SSE frame — it doesn't index a server-side store. The durable conversation lives in your application (in the Bindu reference frontend, that's comms's SQLite events log). ### Compaction-summary sidechannel When the planner compacts overflowing history, it emits an `event: compaction-summary` SSE frame mid-stream. Clients should persist the `summary` field locally and ship it back as `prior_summary` on the next call so the planner keeps the compacted context across requests. ### Catalog immutability per request The `agents` catalog applies to a single `/plan` call. Each request is independent — there's no first-plan / subsequent- plan distinction in stateless mode. ### Streaming & abort Closing the HTTP connection aborts the plan — in-flight A2A calls receive an `AbortSignal` and the planner loop terminates. Clients that want a partial result should buffer `text.delta` frames client-side rather than relying on `final`. security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PlanRequest" examples: minimal: summary: Simplest possible plan (no agents) value: question: "What's the capital of France?" singleAgent: summary: One agent with one skill, no auth value: question: "Find 3 recent papers on LLM evaluation." agents: - name: "research" endpoint: "http://localhost:3773" auth: { type: "none" } skills: - id: "search" description: "Web search." multiAgentDIDSigned: summary: Two agents, DID-signed auth, session continuation value: session_id: "client-session-42" question: "Compare AWS and GCP pricing for a 5-node Kubernetes cluster; then summarize for a non-technical audience." agents: - name: "pricing" endpoint: "https://pricing.example.com" auth: { type: "did_signed" } trust: verifyDID: true pinnedDID: "did:bindu:pricing-agent-key-1" skills: - id: "compare" description: "Compare cloud pricing." inputSchema: type: object properties: provider_a: { type: "string" } provider_b: { type: "string" } workload: { type: "string" } required: [provider_a, provider_b, workload] - name: "summarizer" endpoint: "https://summarize.example.com" auth: type: "bearer_env" envVar: "SUMMARIZER_TOKEN" skills: - id: "summarize" description: "Summarize text for a target audience." preferences: max_steps: 8 timeout_ms: 60000 responses: "200": description: | SSE stream of the plan. Each event is one of the types documented under `SSEEvent` below. The stream closes after `event: done`. content: text/event-stream: schema: $ref: "#/components/schemas/SSEStream" examples: happyPath: summary: Plan with one tool call and a final answer value: | event: session data: {"session_id":"s_01H...","external_session_id":"client-session-42","created":true} event: plan data: {"plan_id":"m_01H...","session_id":"s_01H..."} event: task.started data: {"task_id":"call_01H...","agent":"research","agent_did":null,"skill":"search","input":{"input":"Find 3 recent papers on LLM evaluation."}} event: task.artifact data: {"task_id":"call_01H...","agent":"research","agent_did":null,"content":"Paper A ...\nPaper B ...\nPaper C ...","title":"@research/search"} event: task.finished data: {"task_id":"call_01H...","agent":"research","agent_did":null,"state":"completed"} event: text.delta data: {"session_id":"s_01H...","part_id":"p_01H...","delta":"Here are three recent papers on LLM evaluation:\n\n"} event: final data: {"session_id":"s_01H...","stop_reason":"stop","usage":{"inputTokens":1820,"outputTokens":312,"totalTokens":2132,"cachedInputTokens":0}} event: done data: {} "400": description: | Malformed JSON, missing required fields, schema validation failure, or a catalog that would produce colliding tool ids (two entries whose `_` combination normalizes to the same value — silently swallowed before this guard, which let one peer mask another). content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" examples: missingField: summary: Schema validation failure value: error: "invalid_request" detail: "question: Required; question must be a non-empty string" collidingToolIds: summary: Two catalog entries produce the same normalized tool id value: error: "invalid_request" detail: 'agents catalog has colliding tool ids — toolId "call_research_search" produced by: research/search, research/search' "401": description: Missing or invalid bearer token. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" example: error: "unauthorized" "500": description: | Internal error before the SSE stream opens — e.g. planner misconfiguration or an exception during request handling. Once streaming starts, errors surface as `event: error` on the stream instead. The `session_failed` shape from the stateful era is gone; the stateless gateway has no session-creation step that can fail at the DB layer. content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" example: error: "internal_error" detail: "planner: model unavailable" /health: get: tags: [Health] operationId: getHealth summary: Liveness and basic configuration probe. description: | Unauthenticated, cheap, returns immediately. Does NOT verify downstream connectivity (Supabase, OpenRouter, Hydra) — it only reports whether the gateway process has booted with the expected config. Use this for container liveness checks; for readiness probes that include downstream health, build a higher-level check. security: [] responses: "200": description: | Gateway is up. Response body describes the process — version, identity, configured planner model, recipe count, uptime. The 200 status is informational, not a health gate: read `status` and `ready` in the body to distinguish healthy from degraded. content: application/json: schema: $ref: "#/components/schemas/HealthResponse" example: version: "0.1.0" health: "healthy" runtime: storage_backend: "stateless" bus_backend: "EffectPubSub" planner: model: "openrouter/anthropic/claude-sonnet-4.6" provider: "openrouter" model_id: "anthropic/claude-sonnet-4.6" temperature: 0.3 top_p: null max_steps: 10 recipe_count: 2 did_signing_enabled: true hydra_integrated: true application: name: "@bindu/gateway" session_mode: "stateless" gateway_did: "did:bindu:ops_at_example_com:gateway:f72ba681-f873-324c-6012-23c4d5b72451" gateway_id: "f72ba681-f873-324c-6012-23c4d5b72451" author: "ops_at_example_com" system: node_version: "v22.22.1" platform: "darwin" architecture: "arm64" environment: "development" status: "ok" ready: true uptime_seconds: 2.4 /.well-known/did.json: get: tags: [Identity] operationId: getDidDocument summary: The gateway's self-published DID document. description: | Returns a W3C DID Core v1-compatible document with the gateway's Ed25519 public key under `authentication[]`. A2A peers that accept `did_signed` requests fetch this to verify the gateway's outbound signatures. **Availability:** only registered when the gateway has a DID identity configured via env — `BINDU_GATEWAY_DID_SEED`, `BINDU_GATEWAY_AUTHOR`, and `BINDU_GATEWAY_NAME` all set. When no identity is loaded this endpoint returns 404. **Caching:** the gateway's DID is stable across process lifetime (env-driven); responses carry `Cache-Control: public, max-age=300` as a defense against bad caches that would otherwise hold the key indefinitely. **Content-Type:** `application/did+json` per W3C DID Core, not plain `application/json`. Some DID resolvers enforce the media type. **Auth:** none. Well-known endpoints are public by spec — the whole point is that any peer can resolve the DID without credentials. security: [] responses: "200": description: DID document for the configured gateway identity. headers: Cache-Control: schema: type: string example: "public, max-age=300" content: application/did+json: schema: $ref: "#/components/schemas/GatewayDidDocument" example: "@context": - "https://www.w3.org/ns/did/v1" - "https://getbindu.com/ns/v1" id: "did:bindu:gateway-prod-key-1" authentication: - id: "did:bindu:gateway-prod-key-1#key-1" type: "Ed25519VerificationKey2020" controller: "did:bindu:gateway-prod-key-1" publicKeyBase58: "6MkjQ2r..." "404": description: No DID identity configured on this gateway instance. components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: opaque description: | Shared-secret bearer token(s) configured via `config.gateway.auth.tokens`. Validated in constant time against a SHA-256 hash of each configured token, so neither timing nor length leaks which token matched. Set `gateway.auth.mode: "none"` in config to disable bearer auth (not recommended outside of localhost). schemas: # ----------------------------------------------------------------- # /plan request # ----------------------------------------------------------------- PlanRequest: type: object additionalProperties: true required: [question] properties: question: type: string minLength: 1 description: | The user's natural-language question. Non-empty — an empty string is rejected upstream because some LLM providers (Anthropic) reject empty user messages with a 400 mid-stream, surfacing as a vague "Provider returned error". Validating here gives a clean 400 with `invalid_request` instead. example: "Summarize the latest quarterly results for Apple." agents: type: array default: [] description: | Catalog of A2A peers the planner may call. Empty array = planner runs with no tools (useful for questions the configured planner LLM can answer on its own, e.g., general knowledge). items: $ref: "#/components/schemas/AgentRequest" preferences: $ref: "#/components/schemas/PlanPreferences" session_id: type: string description: | Opaque correlation tag the caller chooses. Echoed back on the first SSE `session` frame as `external_session_id`. In the stateless gateway this is NOT a resumption key — the gateway has no persistent session store. Pass prior turns explicitly via `history` (and optionally a `prior_summary`) to give the planner context across calls. example: "client-session-42" history: type: array description: | Prior conversation for this session. The client (canonical record) sends the most recent turns on every call so the planner has context. Older turns that didn't fit into the cap are preserved as the `prior_summary` field instead. Order: oldest → newest. Omit (or send `[]`) for a brand- new session. Reference frontend (comms) caps this at 30 turns; bigger payloads work but waste tokens. items: $ref: "#/components/schemas/HistoryTurn" prior_summary: type: string description: | Compaction summary the gateway emitted on a prior call, persisted by the client. The planner sees it as a synthetic user turn at the head of history: "[Prior session context, compacted]\n\n...". Omit on first call. example: "[Prior session context, compacted]\n\nUser asked about Apple Q3 earnings; planner ran research_agent.search then summarizer.summarize…" HistoryTurn: type: object required: [role, parts] description: | One user/assistant turn from prior conversation. Minimal shape — no metadata, no ids — because the gateway doesn't persist these anymore; they're only used to populate the planner's prompt for the current call. properties: role: type: string enum: [user, assistant] parts: type: array minItems: 1 items: type: object required: [type, text] properties: type: type: string enum: [text] text: type: string AgentRequest: type: object required: [name, endpoint] properties: name: type: string description: | Display name of the peer. Used to derive the tool id exposed to the planner LLM (`call__`) and to correlate SSE events back to the catalog entry. Operator-chosen and potentially collision-prone — use `trust.pinnedDID` for a cryptographically stable identifier. example: "research" endpoint: type: string format: uri description: | Absolute HTTP(S) URL where the peer's A2A endpoint is reachable. The gateway POSTs JSON-RPC envelopes here for `message/send` and `tasks/get`. example: "http://localhost:3773" auth: $ref: "#/components/schemas/PeerAuth" trust: $ref: "#/components/schemas/PeerTrust" skills: type: array default: [] description: | Peer capabilities the planner may invoke. Each becomes one dynamic tool scoped to this request. The gateway does NOT discover skills from the peer's `AgentCard` — the caller declares them, ensuring the planner sees only capabilities the caller vouches for. items: $ref: "#/components/schemas/SkillRequest" SkillRequest: type: object required: [id] properties: id: type: string description: | The skill id the A2A peer recognizes. Passed back to the peer inside `message/send` so it can route to the right internal handler. example: "search" description: type: string description: | Human-readable description. The planner LLM relies heavily on this to decide whether to invoke the skill — write 3–4 sentences covering intent, inputs, outputs, and when to use it. Descriptions under 120 chars are auto-padded server-side with agent/skill context so the LLM still gets enough signal. example: "Search the open web and return a ranked list of passages." inputSchema: description: | Optional JSON Schema for structured inputs. When present, the planner LLM emits a JSON object matching this shape and the gateway forwards it as the message text (serialized). When omitted, the planner sends a plain-text `input` string. type: object additionalProperties: true outputModes: type: array items: type: string description: | Advisory list of output MIME-like hints the peer may return (e.g., `text/plain`, `application/json`). Surfaced in the tool description so the planner knows what to expect back. example: ["text/plain", "application/json"] tags: type: array items: type: string description: | Free-form tags — helps the planner disambiguate when multiple peers expose similarly-named skills. example: ["research", "web"] PeerAuth: description: | How the gateway authenticates its outbound calls to this peer. Discriminated on `type`: - `none` — anonymous; peer must accept unauthenticated calls. - `bearer` — static token passed literally in `Authorization`. Caller includes the secret in the request, so only use over TLS. - `bearer_env` — gateway reads the token from the named env var. Keeps secrets out of the wire; rotation = restart. - `did_signed` — gateway signs the request body with its configured Ed25519 identity and attaches an OAuth2 token. By default uses the gateway's own auto-acquired Hydra token; pass `tokenEnvVar` to use a per-peer federated token. oneOf: - $ref: "#/components/schemas/PeerAuth_None" - $ref: "#/components/schemas/PeerAuth_Bearer" - $ref: "#/components/schemas/PeerAuth_BearerEnv" - $ref: "#/components/schemas/PeerAuth_DidSigned" discriminator: propertyName: type mapping: none: "#/components/schemas/PeerAuth_None" bearer: "#/components/schemas/PeerAuth_Bearer" bearer_env: "#/components/schemas/PeerAuth_BearerEnv" did_signed: "#/components/schemas/PeerAuth_DidSigned" PeerAuth_None: type: object required: [type] properties: type: type: string enum: [none] PeerAuth_Bearer: type: object required: [type, token] properties: type: type: string enum: [bearer] token: type: string description: "Literal bearer token to include in `Authorization: Bearer `." PeerAuth_BearerEnv: type: object required: [type, envVar] properties: type: type: string enum: [bearer_env] envVar: type: string description: Name of the env var on the gateway process whose value is the bearer token. example: "PEER_A_TOKEN" PeerAuth_DidSigned: type: object required: [type] properties: type: type: string enum: [did_signed] tokenEnvVar: type: string description: | Optional. Env var name for a pre-acquired OAuth2 token to pair with the DID signature. Omit to use the gateway's own Hydra auto-acquired token (requires `BINDU_GATEWAY_HYDRA_*` env). PeerTrust: type: object description: | Per-peer trust policy. Both fields are optional; omitting both means "trust the peer's identity at face value — don't verify." properties: verifyDID: type: boolean description: | When true, the gateway verifies every Ed25519 signature on artifacts returned by this peer. Mismatched signatures fail the task. Requires a resolvable DID on the peer. pinnedDID: type: string description: | DID the peer is expected to present. Used both for correlation (SSE `agent_did`) and, when `verifyDID` is true, to reject responses signed by a different key. example: "did:bindu:research-agent-key-1" PlanPreferences: type: object additionalProperties: true description: | Caps and shaping hints. All keys are **snake_case**; an earlier draft declared them camelCase, which caused docs-compliant clients to silently lose the caps — the schema is now strict on casing and unknown keys pass through via `additionalProperties: true` for forward compatibility. properties: response_format: type: string description: | Advisory hint for the planner's final-message format (`"markdown"`, `"plain"`, `"json"`, etc.). Not enforced by the gateway; the planner may honor or ignore it. max_hops: type: integer minimum: 1 description: | Maximum number of A2A hops (recursive peer-to-peer calls) the gateway allows. Phase 2+ enforced; currently informational. timeout_ms: type: integer minimum: 1000 maximum: 21600000 description: | Overall wall-clock budget for the `/plan` call, in milliseconds. Applies to the entire planner loop including LLM calls, compaction, and every downstream peer call combined. When the budget expires, in-flight peer polls are aborted and a best-effort `tasks/cancel` is dispatched to each peer; the gateway then returns `BinduError(-32040, AbortedByCaller)` with `data.reason = "deadline"`. Default when unset: **1,800,000** ms (30 minutes). Minimum: 1,000 ms. Maximum: 21,600,000 ms (6 hours). Requests above the ceiling are rejected at the API boundary as `invalid_request` — callers with genuine multi-hour workloads set it explicitly. example: 1800000 max_steps: type: integer minimum: 1 description: | Maximum agentic loop steps. Overrides the planner agent's default (`agent.steps`). A "step" is one LLM call — tool calls inside a step don't count. example: 8 # ----------------------------------------------------------------- # Responses # ----------------------------------------------------------------- HealthResponse: type: object required: [version, health, runtime, application, system, status, ready, uptime_seconds] description: | Detailed gateway health payload. Shape aligned with the per-agent Bindu health (the one a `bindufy()`-built agent returns), adapted for the coordinator role: `gateway_id`/`gateway_did` replace the agent-side `penguin_id`/`agent_did`, and `runtime` reports gateway-specific knobs (planner model, recipe count, DID-signing status) instead of the agent's task-manager fields. properties: version: type: string description: Gateway package version, from gateway/package.json. example: "0.1.0" health: type: string enum: [healthy, degraded, unhealthy] description: | Overall classification. - `healthy`: every boot invariant satisfied, planner model resolves. - `degraded`: non-critical subsystem missing (reserved — no current signals trigger this). - `unhealthy`: a required invariant is broken (e.g. no planner model configured). runtime: $ref: "#/components/schemas/HealthRuntime" application: $ref: "#/components/schemas/HealthApplication" system: $ref: "#/components/schemas/HealthSystem" status: type: string enum: [ok, error] description: Two-state mirror of `health` — `ok` when healthy, `error` when unhealthy. Provided for operators that prefer binary. ready: type: boolean description: Liveness gate. True when every boot invariant is satisfied. Use this for k8s readiness probes via a `jq` post-processor. uptime_seconds: type: number description: Seconds since gateway process boot (float, 2 decimal places). example: 23.3 HealthRuntime: type: object required: [storage_backend, bus_backend, planner, recipe_count, did_signing_enabled, hydra_integrated] properties: storage_backend: type: string enum: [stateless] description: | The gateway's persistence model. Always `stateless` since the Path A migration — session state lives in memory for the lifetime of each `/plan` call only; the calling client owns durable history. bus_backend: type: string description: Event bus driver. Today always `EffectPubSub` (in-process). planner: $ref: "#/components/schemas/HealthPlanner" recipe_count: type: integer description: Number of recipes discovered at boot (union across all scanned directories, after permission filtering for the default agent). example: 2 did_signing_enabled: type: boolean description: True when a gateway DID identity is loaded (env vars `BINDU_GATEWAY_DID_SEED` + friends all set). `did_signed` peers require this. hydra_integrated: type: boolean description: True when a Hydra token provider was successfully wired at boot. `did_signed` peers without `tokenEnvVar` need this to auto-acquire tokens. HealthPlanner: type: object required: [model, provider, model_id, temperature, top_p, max_steps] description: | The planner LLM configuration — what model drives the agentic loop inside every `/plan` call. Sourced from `gateway/agents/planner.md` frontmatter (or config.agent.planner overrides). properties: model: type: [string, "null"] description: Full provider-prefixed model id as configured. Null when no planner agent is configured. example: "openrouter/anthropic/claude-sonnet-4.6" provider: type: [string, "null"] description: Provider segment (bit before the first `/`). Today always `openrouter`. example: "openrouter" model_id: type: [string, "null"] description: Upstream model id the provider understands. For OpenRouter-proxied Anthropic this is `anthropic/claude-sonnet-4.6`. example: "anthropic/claude-sonnet-4.6" temperature: type: [number, "null"] description: Sampling temperature configured on the planner agent. top_p: type: [number, "null"] description: Nucleus sampling top_p. max_steps: type: [integer, "null"] description: Cap on agentic loop steps per plan. Null when no cap is set (the planner will run until natural completion or context overflow). HealthApplication: type: object required: [name, session_mode, gateway_did, gateway_id, author] properties: name: type: string const: "@bindu/gateway" session_mode: type: string enum: [stateless] description: | Session persistence model. Always `stateless` — the `stateful` value (Supabase-backed) was removed in the Path A migration. Clients pass prior turns via `history` on each /plan call. gateway_did: type: [string, "null"] description: The gateway's full DID, null when no identity is configured. example: "did:bindu:ops_at_example_com:gateway:f72ba681-f873-324c-6012-23c4d5b72451" gateway_id: type: [string, "null"] description: Short identifier — last segment of the DID (UUID-ish hash of the public key for `did:bindu`). example: "f72ba681-f873-324c-6012-23c4d5b72451" author: type: [string, "null"] description: Author segment from the DID. Null for non-Bindu DIDs or when no identity is configured. example: "ops_at_example_com" HealthSystem: type: object required: [node_version, platform, architecture, environment] properties: node_version: type: string description: Node.js runtime version. example: "v22.22.1" platform: type: string description: Underlying OS kernel identifier from `process.platform`. example: "darwin" architecture: type: string description: CPU architecture from `process.arch`. example: "arm64" environment: type: string description: Value of `NODE_ENV`, or `"development"` when unset. example: "development" GatewayDidDocument: type: object required: ["@context", id, authentication] description: | W3C DID Core v1 document describing the gateway's identity. Deliberately omits `created` — the gateway's identity is env- driven and stateless, so there's no persisted "first published" moment to report (W3C DID Core has `created` as optional). properties: "@context": type: array items: type: string example: - "https://www.w3.org/ns/did/v1" - "https://getbindu.com/ns/v1" id: type: string example: "did:bindu:gateway-prod-key-1" authentication: type: array items: $ref: "#/components/schemas/GatewayVerificationMethod" GatewayVerificationMethod: type: object required: [id, type, controller, publicKeyBase58] properties: id: type: string example: "did:bindu:gateway-prod-key-1#key-1" type: type: string enum: [Ed25519VerificationKey2020] controller: type: string example: "did:bindu:gateway-prod-key-1" publicKeyBase58: type: string description: Ed25519 public key, base58-encoded. example: "6MkjQ2r..." ErrorResponse: type: object required: [error] properties: error: type: string enum: [unauthorized, invalid_request, internal_error] description: Machine-readable error code. detail: type: string description: Human-readable explanation. Absent for `unauthorized` (don't leak whether a token matched any configured value). # ----------------------------------------------------------------- # SSE stream — descriptive schemas # ----------------------------------------------------------------- SSEStream: type: string description: | The `text/event-stream` body is a sequence of `event:` / `data:` pairs. Each `data:` value is a JSON object matching one of the `SSEEvent_*` schemas below. OpenAPI doesn't model SSE natively; `$ref` the per-event schemas to generate typed consumers. SSEEvent_Session: type: object description: | Emitted first, before the plan starts. Carries session identifiers so clients can cache them for resume. required: [session_id, external_session_id, created] properties: session_id: type: string description: Server-assigned internal session id. Stable across resumes. example: "s_01H..." external_session_id: type: [string, "null"] description: Echo of `session_id` from the request body, if provided. created: type: boolean description: True if this is a freshly created session; false if resumed. SSEEvent_Plan: type: object required: [plan_id, session_id] properties: plan_id: type: string description: Unique id for this planner turn (the assistant message id). session_id: type: string SSEEvent_TextDelta: type: object required: [session_id, part_id, delta] properties: session_id: type: string part_id: type: string description: Unique id for the text part. Multiple `text.delta` frames share a `part_id` — concatenate their `delta` fields in order. delta: type: string description: Incremental UTF-8 text chunk. May contain partial multi-byte characters across delta boundaries in theory; OpenRouter does not split these in practice. SSEEvent_TaskStarted: type: object required: [task_id, agent, agent_did, agent_did_source, skill, input] properties: task_id: type: string description: Unique per tool call. Correlates with the matching `task.artifact` + `task.finished` frames. agent: type: string description: Display name of the peer agent (from `agents[].name`). agent_did: type: [string, "null"] description: | The peer's DID, resolved with precedence pinned → observed → null: (a) `trust.pinnedDID` from the /plan catalog if set; otherwise (b) the DID the peer published at `/.well-known/agent.json`, fetched by the gateway at plan-open time; otherwise (c) `null` — cryptographic identity undeclared. See `agent_did_source` for which path resolved it. agent_did_source: $ref: "#/components/schemas/AgentDIDSource" skill: type: string description: Skill id being invoked on the peer. input: description: | The JSON payload the planner sent to the tool — either the structured object matching `SkillRequest.inputSchema` or the `{input: ""}` default-schema shape. type: object additionalProperties: true SSEEvent_TaskArtifact: type: object required: [task_id, agent, agent_did, agent_did_source, content] properties: task_id: type: string agent: type: string agent_did: type: [string, "null"] description: Same resolution rules as on `task.started` — pinned → observed → null. agent_did_source: $ref: "#/components/schemas/AgentDIDSource" content: type: string description: | The peer's artifact text, wrapped in a `...` envelope. The planner treats this as untrusted data — clients should too. `verified` is four-valued: - `yes` → at least one signed artifact and all signed verified against the pinned DID's public key. Strongest guarantee. - `no` → at least one signed artifact failed verification. Task is also marked `failed`. - `unsigned` → verification ran but no artifact carried a signature. The body is unverified hearsay. - `unknown` → verification wasn't attempted (no `verifyDID`, no `pinnedDID`, or DID doc unreachable). title: type: string description: Short display title, typically `@/`. signatures: $ref: "#/components/schemas/PlanSignatures" description: | Signature-verification outcome for this peer call. Present only when the caller set `trust.verifyDID: true` on the agent in the /plan request and the gateway attempted verification. Absent on `load_recipe` / other local tool calls that don't involve a peer. A `null` here means verification was configured but skipped at run time (no pinnedDID, DID doc unreachable, or no usable public key in the doc) — distinct from absence, which means "not even attempted". SSEEvent_TaskFinished: type: object required: [task_id, agent, agent_did, agent_did_source, state] properties: task_id: type: string agent: type: string agent_did: type: [string, "null"] description: Same resolution rules as on `task.started` — pinned → observed → null. agent_did_source: $ref: "#/components/schemas/AgentDIDSource" state: type: string enum: [completed, failed] description: | Terminal state of the A2A task from the gateway's perspective. Non-terminal states on the A2A peer (`input-required`, `auth-required`, `payment-required`) surface as `completed` here with the prompt in `task.artifact.content`; the planner decides whether to retry or surface to the user. signatures: $ref: "#/components/schemas/PlanSignatures" description: | Same shape as on `task.artifact` — duplicated here so consumers that only subscribe to `task.finished` (e.g. for audit logging) still see the verification outcome. error: type: string description: "Present only when `state: failed`. Human-readable." SSEEvent_Final: type: object required: [session_id, stop_reason] properties: session_id: type: string stop_reason: type: string enum: [stop, length, tool-calls, content-filter, error] description: | Why the planner stopped: - `stop` — natural end (assistant message complete). - `length` — hit the model's max output length. - `tool-calls` — tool call emitted but loop cap reached. - `content-filter` — provider-side content filter triggered. - `error` — runtime error during streaming. usage: $ref: "#/components/schemas/PlanUsage" SSEEvent_Error: type: object required: [message] properties: message: type: string description: Human-readable error message. Always followed by a `done` frame. SSEEvent_Done: type: object description: Empty object. Last frame of every successful plan. additionalProperties: false SSEEvent_CompactionSummary: type: object required: [session_id, summary] description: | Emitted whenever the planner's compaction layer runs and produces a new summary (mid-stream, between planner steps). The client must persist `summary` keyed by `session_id` and ship it back as `prior_summary` on the next `/plan` call. Without that loop, every long session re-pays the summarisation cost on each request because the planner sees no compacted context. Frame placement is non-deterministic — depends on when history overflows the model's context window. Short sessions may never produce one; long ones may produce several across consecutive `/plan` calls. properties: session_id: type: string summary: type: string description: | One paragraph (typically 200–800 tokens) that captures the load-bearing facts from the compacted head turns. Persist verbatim — the planner trusts that it carries forward every prior compaction's content. tokens_before: type: integer description: Total history tokens (estimated) just before this compaction ran. tokens_after: type: integer description: Total history tokens after compaction (tail + summary). messages_compacted: type: integer description: How many real (non-synthetic) messages were folded into the summary. AgentDIDSource: type: [string, "null"] enum: [pinned, observed, null] description: | Provenance of the `agent_did` on the same SSE frame. Tells consumers which of three paths resolved the DID, so they can apply the right trust policy: - `"pinned"` — the caller declared `trust.pinnedDID` in the /plan catalog. The caller vouched for this identity; the gateway enforces it when `verifyDID: true` is also set. Strongest claim a consumer can get out of this field. - `"observed"` — the peer self-reported this DID in its `/.well-known/agent.json` AgentCard, fetched by the gateway at plan-open time. Weaker than pinned: an impostor standing up a fake endpoint can advertise any DID they choose unless signature verification is on. - `null` — neither path resolved. Either the caller didn't pin AND the AgentCard couldn't be fetched (no `.well-known`, network failure, malformed), or the AgentCard had no DID in either `id` or `capabilities.extensions[].uri`. Compliance-gated consumers should treat `"observed"` and `null` identically unless they also see a `signatures.ok:true` with `signed > 0` on the same or a following frame — that's the cryptographic evidence that promotes an observed DID to a verified one. PlanSignatures: type: [object, "null"] description: | DID-signature verification outcome for one peer call. Emitted on `task.artifact` and `task.finished` when the caller set `trust.verifyDID: true` on the agent in the /plan request. **How to interpret the counts:** - `signed > 0 && signed === verified` — every artifact that carried a signature checked out against the pinned DID's public key. Strongest guarantee. - `signed === 0 && unsigned > 0` — artifacts came back but none had signatures. The gateway will still report `ok:true` (nothing to fail), but the `verified="yes"` on the `` envelope is a *vacuous* yes — there was nothing to verify. Check the agent's signing config. - `signed > 0 && signed !== verified` — at least one signature didn't match. `ok:false`. The task will also be marked `failed` and surface an error. - Field is `null` — `verifyDID` was enabled but verification couldn't run: pinnedDID missing, DID doc unreachable, or no usable public key in the doc. - Field absent entirely — verification wasn't attempted (no `verifyDID: true`, or this tool call wasn't a peer call — e.g. `load_recipe`). properties: ok: type: boolean description: True when no signed artifact failed verification. Note — if NO artifacts were signed (signed === 0) this is vacuously true; always cross-reference `signed`. signed: type: integer minimum: 0 description: Number of artifacts that carried a signature header. verified: type: integer minimum: 0 description: Of the signed artifacts, how many passed verification against the pinned DID's public key. unsigned: type: integer minimum: 0 description: Number of artifacts that had no signature attached. Informational — doesn't affect `ok`. PlanUsage: type: object description: | Token accounting for the planner turn. Values come from the provider's usage block; fields may be absent if the provider didn't return them. properties: inputTokens: type: integer description: Tokens in the combined prompt (system + history + tools). outputTokens: type: integer description: Tokens in the assistant output (text + tool call JSON). totalTokens: type: integer cachedInputTokens: type: integer description: Tokens served from the provider's prompt cache (OpenRouter + Anthropic ephemeral cache).