Skip to content

Latest commit

 

History

History
283 lines (225 loc) · 11 KB

File metadata and controls

283 lines (225 loc) · 11 KB

@lucid-agents/http

Fetch-native HTTP handlers, SSE helpers, a canonical route plan, and the shared entrypoint authorization gate for Lucid Agents.

Use as an extension

import { createAgent } from '@lucid-agents/core';
import { http } from '@lucid-agents/http';

const agent = await createAgent({ name: 'echo', version: '1.0.0' })
  .use(http({ basePath: '/api/agent', servicePage: { preset: 'dossier' } }))
  .addEntrypoint({
    key: 'echo',
    handler: async ({ input }) => ({ output: input }),
  })
  .build();

agent.http.basePath; // '/api/agent'
agent.http.handlers; // transport-neutral Fetch handlers
agent.http.routes; // canonical route/capability plan

Service page model

buildServicePageModel(card, options) converts a public Agent Card and health response into the canonical storefront model used by generated UIs:

import { buildServicePageModel } from '@lucid-agents/http';

const card = await agent.http.handlers
  .manifest(new Request('https://agent.example/.well-known/agent-card.json'))
  .then(response => response.json());

const service = buildServicePageModel(card, {
  health: { ok: true, version: card.version },
});

The model preserves public identity/provider metadata, protocol interfaces, health, trust, offerings, schemas, examples, modes, security, prices, payment and SIWX requirements, skills, task support, endpoints, and extension descriptors. It does not accept or expose private runtime configuration.

The default Hono and Express landing page renders the same model as a minimal, portable endpoint directory. It contains no client JavaScript, invoke controls, schemas, or raw Agent Card JSON. Every invoke and stream operation appears once with its HTTP path, payment method, and price. Priced operations receive a high-contrast price treatment, while a compact Resolved Core mark identifies the quiet "Powered by Lucid Agents" footer attribution.

Service UI configuration

Choose one of three built-in designs and optionally override bounded semantic tokens:

import { defineServiceUi } from '@lucid-agents/http/service-ui';

export default defineServiceUi({
  preset: 'folio', // dossier | folio | console
  tokens: {
    colors: { accent: '#2B302C' },
    fonts: {
      body: ['Instrument Sans', 'Avenir Next', 'sans-serif'],
      stylesheetUrl: '/fonts/service-ui.css',
    },
  },
});

Pass the config to http({ servicePage }). Use servicePage: false for an API-only runtime. landingPage remains as a deprecated compatibility switch. Unknown keys, unsafe fonts/URLs, invalid hex values, and inaccessible primary color combinations fail during runtime construction.

Install domain extensions such as payments(), mpp(), identity(), and a2a() before HTTP when possible. The kernel also honors HTTP's ordering constraints if call-site order differs.

Route plan

agent.http.routes is the only route definition used by the server adapters. Each item contains a stable ID, method, path, path parameter names, and a handle(Request, params) function.

With no base path, the plan includes:

  • GET /health and GET /entrypoints;
  • POST /entrypoints/:key/invoke and /stream;
  • GET /.well-known/agent-card.json and the legacy agent.json alias;
  • GET /.well-known/oasf-record.json and /favicon.svg;
  • the landing route, unless disabled;
  • task routes only when a2a() is installed.

basePath prefixes every capability consistently and is written into the agent card's HTTP interface URL.

An adapter can bind the route plan without depending on its internals:

for (const route of agent.http.routes) {
  framework.route(route.method, route.path, request =>
    route.handle(request, framework.params(request))
  );
}

Hono and Express already do this. Generated Next and TanStack route modules call the same handler contract.

Authorization

Invoke, stream, and task creation all call one authorization transaction. It:

  1. resolves one payment rail for the canonical entrypoint;
  2. verifies SIWX, x402, or MPP credentials in the owning extension;
  3. wins the target-side idempotency claim for invokes;
  4. admits the request by evaluating and reserving incoming policy capacity;
  5. executes an invoke or admits stream/task work;
  6. finalizes settlement/recording or releases reservations on a pre-admission failure.

Invoke finalization follows the handler result. Streaming and task requests finalize when their asynchronous work is successfully admitted, since their HTTP response is already live or accepted at that point.

Task creation has an additional ordering guarantee. An initial MPP challenge does not reserve task capacity; a credential-bearing MPP request reserves its durable task before verification can commit. HTTP then holds a renewable prepared execution claim while finalizing x402 or MPP settlement and atomically activates it before starting the handler or its timeout. Uncommitted failures terminalize reserved state and release payment policy capacity. Once a rail commits, any later execution-start or accounting failure returns the receipt with a queryable terminal task capability.

Paid task creation requires a task store that truthfully declares durability: 'durable'; the default process-local A2A store is accepted only for free tasks. It also requires a caller-supplied Task-Access-Token, ensuring the payer knows the recovery capability before authorization begins; the A2A sendMessage() client supplies one automatically. These checks happen before payment authorization. The client also sends an Idempotency-Key, returns settlement headers on success, and exposes both keys plus any task/settlement evidence through TaskCreationError when a response is interrupted or non-successful.

The two-phase task transaction covers all HTTP response paths in a running worker. It cannot atomically commit an external payment provider and a task database across abrupt process loss. Production payment verifiers must deduplicate settlement with the caller's Idempotency-Key and provide receipt reconciliation; multi-worker hosts must persist invocation data needed to resume an expired active task lease.

Task creation is not target-side idempotent. The idempotency header is a settlement-deduplication input for payment verifiers; clients must list or otherwise reconcile durable task state before retrying a creation request.

If both x402 and MPP are installed, every priced entrypoint must set paymentProtocol: 'x402' | 'mpp'. An auth-only entrypoint requires an enabled SIWX payments runtime. These conditions are validated when the entrypoint is registered, including dynamic registration after build.

authorizeEntrypointRequest is exported for a custom transport that needs the same gate. After successful verification, call authorization.admit(). Only an admitted request may execute; call the admission's finalize(response) exactly once for every admitted request, including error responses. Call abort() if the transport abandons admitted work before it has a response.

Handlers

Handlers accept standard Web Request objects and return Response objects:

const response = await agent.http.handlers.invoke(
  new Request('https://example.test/api/agent/entrypoints/echo/invoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ input: { value: 1 } }),
  }),
  { key: 'echo' }
);

The manifest handler builds by request origin, so generated compatibility routes must delegate to it rather than cache runtime.manifest.build() independently.

Idempotent invoke

Target-side invoke idempotency is enabled by default. Send a stable 20–256 character Idempotency-Key when retrying an operation:

await fetch('https://agent.example/entrypoints/charge/invoke', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': 'charge:customer-42:invoice-2026-07',
  },
  body: JSON.stringify({ input: { amount: 10 } }),
});

The key is scoped to the entrypoint and bound to the request body, ambient authorization/cookie context, and a stable identity derived from the freshly verified SIWX or payment credential. Verification happens before the claim; policy reservation and settlement happen only after a new claim is won. A completed 2xx response is replayed only for the same verified subject, with Idempotency-Replayed: true; a concurrent duplicate or reuse with different input returns 409. Authorization challenges and application failures release the claim so they can be retried. If storing a completed response fails after execution, HTTP returns 503 and retains the active claim to prevent duplicate execution or settlement.

The default store is bounded and process-local. Multi-instance deployments must inject an atomic durable store:

http({
  idempotency: {
    store: durableIdempotencyStore,
    inProgressTtlMs: 15 * 60_000,
    retentionMs: 24 * 60 * 60_000,
  },
});

Set idempotency: false only when the application intentionally provides an equivalent deduplication boundary elsewhere.

Streaming

Streaming entrypoints use SSE envelopes. A stream handler receives an emit function as its second argument and returns an optional final result:

.addEntrypoint({
  key: 'tokens',
  stream: async ({ input }, emit) => {
    await emit({ kind: 'text', text: String(input) });
    return { output: { done: true }, status: 'succeeded' };
  },
})

Low-level createSSEStream and writeSSE helpers are available for custom transports. Envelope and route contracts are defined in @lucid-agents/types/http.

createSSEStream() exposes asynchronous ready(), write(), and close() operations. Awaiting them applies response-consumer backpressure. Its runner signal aborts when either the request aborts or the response reader cancels, so generators can stop external work promptly.

An MPP Tempo session-backed stream meters each handler emit() as one configured unit. HTTP waits for downstream capacity, atomically charges the verified session meter, and only then writes the envelope. Session control frames are protocol-native SSE events:

event: payment-need-voucher
data: {"channelId":"0x...","requiredCumulative":"20",...}

event: payment-receipt
data: {"method":"tempo","intent":"session","spent":"20","units":2,...}

The stream waits for a live top-up according to the configured session policy. If funding remains unavailable, it emits the standard session Problem Details document as an error event and terminates with a failed run-end. Consumer cancellation aborts the wait and releases only uncommitted meter work. Shared payment policies reserve the verified maximum before streaming and finalize exactly once with deliveredUnits * unitAmount after completion, failure, or disconnect, so resumed and simultaneous streams do not double-count cumulative channel spend.

Low-level invocation

invoke, invokeHandler, and stream are exported for integration code. They operate on the canonical runtime/entrypoint definitions and perform Zod input and output validation. Prefer the completed HTTP runtime unless you are writing an adapter.