Skip to content

@chamvi/http ​

REST, SSE, webhooks and MCP for chamvi agents, on routup. The public API of packages/http/src/index.ts.

bash
npm install @chamvi/http @chamvi/core --save

@chamvi/core is a peer dependency. See HTTP API & MCP for the behaviour.

createHttpApp ​

ts
function createHttpApp(options: HttpAppOptions): App; // routup App

The store, the agents and the clock come from engine (engine.store, engine.listAgents(), engine.clock); agents narrows the served agents to the named ones, the others get no routes, MCP tools or hooks in this app. Throws InvalidAgentDefinitionError for a name in agents the engine lacks or that is given twice, an empty auth array and an invalid routes entry (a path that is not static, a duplicate method and path, a first segment of a built-in route such as agents, runs or hooks, or the metrics path, all compared case-insensitively).

ts
export type CorsOptions = CorsPluginOptions;

export type RateLimitOptions = RateLimitPluginOptions;

export type PrometheusOptions = PrometheusPluginOptions;

/** routup's trust-proxy input: a predicate, CIDRs, `'loopback'`, a hop count or a boolean. */
export type TrustProxyInput = NonNullable<AppOptionsInput['trustProxy']>;

export type McpOptions = {
    name?: string;
    version?: string;
    maxWaitSeconds?: number;
};

export type HttpAppOptions = {
    /** Its `store`, agents and `clock` serve every route. */
    engine: Engine;
    /**
     * Names of the engine's agents to serve (routes, MCP tools, hooks, extra
     * routes); the others are unknown to this app. Default: every agent.
     */
    agents?: string[];
    /** One authenticator or a chain; the first principal wins. */
    auth: Authenticator | Authenticator[];
    /** Prefix of every route, e.g. `/api`. Default: none. */
    basePath?: string;
    /** Which proxies `getRequestIP` trusts (and so the rate-limit key). Default: none. */
    trustProxy?: TrustProxyInput;
    /** Fails `/readyz` once shutting down; the app ends its streams on shutdown. */
    shutdown?: IShutdown;
    /** Bytes a request body may have. Default: 1 MiB. */
    bodyLimit?: number;
    stream?: {
        /** How often a stream polls the store for runs executing elsewhere. Default: 2 s. */
        pollMs?: number;
        /**
         * Open SSE run streams plus waiting MCP `run_<agent>` calls, per app
         * (a positive integer). Over it a stream answers 503 with
         * `Retry-After`, an MCP call answers without waiting. Default: 1000.
         */
        maxOpen?: number;
    };
    mcp?: false | McpOptions;
    /** CORS is off unless given. */
    cors?: false | CorsOptions;
    rateLimit?: false | Partial<RateLimitOptions>;
    metrics?: false | Partial<PrometheusOptions>;
    /** How long `/readyz` waits for the store. Default: 2 s. */
    readyTimeoutMs?: number;
    /** Receives 5xx causes and background failures. Default: `console.error`. */
    onError?: (error: unknown) => void;
    /** Extra routes other packages contribute (e.g. a GitHub webhook), served under `basePath`. */
    routes?: HttpRoute[];
};

routes takes extra routes from other packages, such as the GitHub webhook of @chamvi/github:

ts
export type HttpRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export type HttpRouteRequest = {
    /** URL, method, headers and signal; without the body, which only readBody() reads. */
    request: Request;
    /**
     * The raw body, at most `bodyLimit` bytes: 413 above it (content-length
     * refused unread, else drained like readJsonBody), 415 for a non-JSON
     * media type with a non-empty body; an empty body is a 0-length array.
     * Memoized: every call answers the same bytes (or the same error).
     */
    readBody(): Promise<Uint8Array>;
};

export type HttpRouteResult = {
    status: number;
    /**
     * Objects, arrays, numbers and booleans are sent as JSON, a string as
     * `text/plain`; `undefined` or `null` answer an empty body.
     */
    body?: unknown;
};

export type HttpRouteContext = {
    engine: Engine;
    agents: readonly Agent[];
    clock: IClock;
    onError: (error: unknown) => void;
};

export type HttpRoute = {
    method: HttpRouteMethod;
    /** Static path under basePath (no ':' '*' '{'), e.g. '/webhooks/github'; also its metrics label. */
    path: string;
    /** false: skipped by the app's rate limiter (callers that never retry, e.g. webhook senders). Default: limited. */
    rateLimit?: false;
    /**
     * Not authenticated by the app's authenticators: the route checks its own
     * credentials. Thrown @ebec/http and core errors render like any route's.
     */
    handler: (request: HttpRouteRequest, ctx: HttpRouteContext) => Promise<HttpRouteResult>;
};

Authenticators ​

ts
type Authenticator = (request: Request) => Promise<Principal | undefined>;

function staticTokens(options: StaticTokensOptions): Authenticator;
function authup(options: AuthupOptions): Authenticator;
function oidc(options: OidcOptions): Authenticator;

An authenticator resolves undefined for a request it does not recognize and throws a 401 for credentials it rejects. staticTokens throws InvalidAgentDefinitionError for an empty tokens array, a token value shorter than 16 characters, a missing id or a value given twice; oidc for an empty issuers list, an issuer without audience or a missing principal.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { Permission } from './constants.ts';

export type PermissionName = typeof Permission[keyof typeof Permission];

export type AgentAction = 'run' | 'approve';

export type Principal = {
    id: string;
    kind: 'token' | 'authup' | 'oidc' | (string & {});
    /** Granted permissions, e.g. `agent:triage:run`, `run:read` or `*`. */
    permissions: readonly string[];
    claims?: Record<string, unknown>;
};

/**
 * Resolves `undefined` when the request carries no credentials it
 * recognizes, throws `UnauthorizedError` for credentials it rejects.
 */
export type Authenticator = (request: Request) => Promise<Principal | undefined>;

Permissions ​

ts
const Permission: {
    ALL: '*';
    RUN_READ: 'run:read';
    RUN_CANCEL: 'run:cancel';
    STATE_READ: 'state:read';
    STATE_WRITE: 'state:write';
};

function agentPermission(agent: string, action: 'run' | 'approve'): string; // agent:<agent>:<action>

Matching uses core's hasPermission(principal.permissions, permission): true for a granted *, or for a grant with the same number of : segments whose every segment is equal or *.

createShutdown ​

ts
function createShutdown(options: ShutdownOptions): IShutdown;

const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS: 30_000;
const DEFAULT_SHUTDOWN_CLOSE_TIMEOUT_MS: 5_000;
ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { Engine } from '@chamvi/core';

export interface IShutdown {
    /** True from the first `shutdown()` call on; `/readyz` then fails. */
    readonly shuttingDown: boolean;
    /** Runs `fn` during the shutdown, after the scheduler stopped; returns an unsubscribe. */
    onShutdown(fn: () => void | Promise<void>): () => void;
    /** Idempotent: every call returns the same promise. */
    shutdown(): Promise<void>;
}

export type ShutdownOptions = {
    /** Drained on shutdown; its `clock` times the delays. */
    engine: Engine;
    scheduler?: { stop(): Promise<void> };
    /** srvx `Server` (pass `gracefulShutdown: false` to `serve`). */
    server?: { close(closeActiveConnections?: boolean): Promise<void> };
    /** Time for load balancers to see the failing readiness. Default 0. */
    readinessDelayMs?: number;
    /** Default 30 s. */
    drainTimeoutMs?: number;
    /** Wait for open connections after the drain, then close them. Default 5 s. */
    closeTimeoutMs?: number;
    /** Default `console.error`. */
    onError?: (error: unknown) => void;
};

Wire types ​

ts
type InvokerView = Omit<Invoker, 'permissions'>;
type RunView = Omit<Run, 'claim' | 'trigger'> & {
    trigger: Omit<TriggerEnvelope, 'invoker'> & { invoker?: InvokerView };
};
type RunSummary = Pick<Run, 'id' | 'agent' | 'status' | 'createdAt' | 'finishedAt' | 'error'>;
type RunToolResult = {
    runId: string;
    status: RunStatusLiteral;
    deduplicated: boolean;
    output?: unknown;
    error?: { code: string; message: string };
    waiting?: { toolName: string; input: unknown; expiresAt: string };
};

AgentSummary and ScheduleSummary are the items of GET /agents and GET /schedules:

ts
/** An agent as `GET /agents` lists it. */
export type AgentSummary = {
    name: string;
    /** `hook` is the ready-made path of a `webhook()` trigger. */
    triggers: Array<{
        source: TriggerSource;
        id: string;
        hook?: string
    }>;
    budget: Budget;
    /** Tool names that need an approval. */
    approvals: string[];
    /** Declared state collections. */
    state: string[];
    notify?: string;
};

/** A `cron()` trigger as `GET /schedules` lists it (definitions only, not scheduler health). */
export type ScheduleSummary = {
    agent: string;
    trigger: string;
    expression: string;
    timezone?: string;
    overlap: OverlapPolicy;
    /** ISO time of the next fire after now. */
    nextAt: string;
};