Cheela Labs
GUIDES

Serve capability calls

Cheela never runs your capability code. When the agent loop needs a capability, it makes a signed HTTPS call to your endpoint, you execute it, and you return the result.

What the endpoint does

Two jobs, in this order: prove the request came from Cheela, then run the capability. The order is not negotiable — an endpoint that dispatches before verifying will execute your capabilities for anyone who knows its URL.

createCheelaHandler does both. It checks, in order, that all four x-cheela-* headers are present, that the signature was issued for this runtime, that the timestamp is inside the tolerance window, that the nonce has not been seen before, and that the HMAC matches in constant time. Only then does it call your capability.

Next.js, Hono, Bun, Deno, Workers

Anything built on the web-standard Request/Response pair uses the same handler.

app/cheela/execute/route.ts
import { createCheelaHandler } from "@cheela/runtime";
import runtime from "../../../.cheela/runtime";

export const POST = createCheelaHandler({
  runtime,
  secret: process.env.CHEELA_RUNTIME_SECRET!,
  runtimeId: process.env.CHEELA_RUNTIME_ID,
});

export const dynamic = "force-dynamic";

If your framework evaluates route modules at build time with no secrets present, read the secret per request instead of at module scope:

app/cheela/execute/route.ts
function requiredSecret(): string {
  const secret = process.env.CHEELA_RUNTIME_SECRET;
  if (!secret) {
    throw new Error(
      "CHEELA_RUNTIME_SECRET is not set. It is shown once when the runtime is " +
        "created; without it this endpoint cannot tell a real Cheela request " +
        "from anyone else's.",
    );
  }
  return secret;
}

export async function POST(request: Request): Promise<Response> {
  const handler = createCheelaHandler({
    runtime,
    secret: requiredSecret(),
    runtimeId: process.env.CHEELA_RUNTIME_ID,
  });
  return handler(request);
}

The failure belongs to a request, not to a build. And note that the secret is never defaulted to "" — a security parameter should not have a fallback.

Express

Mount it with a raw body parser. This is not a style preference: express.json() discards the exact bytes the signature covers.

server.ts
import express from "express";
import { createCheelaExpressHandler } from "@cheela/runtime";
import runtime from "./.cheela/runtime";

const app = express();

app.post(
  "/cheela/execute",
  express.raw({ type: "*/*" }),
  createCheelaExpressHandler({
    runtime,
    secret: process.env.CHEELA_RUNTIME_SECRET!,
  }),
);

Get this wrong and the handler answers 400 raw_body_required with an explanation, rather than letting every request fail as signature_mismatch — which reads like a wrong secret and sends you off rotating a credential that was fine.

Verifying by hand

If you are not on either shape, use the primitive directly.

TypeScript
import { verifyCheelaSignature, MemoryNonceStore } from "@cheela/runtime";

const nonceStore = new MemoryNonceStore();

const result = await verifyCheelaSignature({
  secret: process.env.CHEELA_RUNTIME_SECRET!,
  headers: request.headers,   // a Headers object, or a lower-cased record
  rawBody: await request.text(), // the raw bytes, not parsed JSON
  runtimeId: process.env.CHEELA_RUNTIME_ID,
  nonceStore,
});

if (!result.valid) {
  return new Response(result.reason, { status: 401 });
}

result.reason is one of six values, and each points at a different fix:

REASONWHAT IT MEANS
missing_headersOne of the four x-cheela-* headers did not arrive. Check for a proxy stripping them.
runtime_mismatchSigned for a different runtime than the one you pinned.
timestamp_invalidThe timestamp header was not a number.
timestamp_outside_toleranceMore than five minutes of clock skew, in either direction.
nonce_replayedThis nonce has been used. Either a genuine replay, or a shared store you do not have.
signature_mismatchWrong secret — or, far more often, a body that was re-serialized.

Replay protection at scale

MemoryNonceStore is per-process, which is correct for a single instance. Behind a load balancer, a captured request can be replayed once per instance until each has seen the nonce.

The NonceStore interface is one method, so back it with whatever you already run:

TypeScript
import type { NonceStore } from "@cheela/runtime";

const redisNonceStore: NonceStore = {
  async claim(nonce, expiresAt) {
    // SET NX returns null when the key already exists.
    const claimed = await redis.set(`cheela:nonce:${nonce}`, "1", {
      NX: true,
      PXAT: expiresAt,
    });
    return claimed !== null;
  },
};

export const POST = createCheelaHandler({
  runtime,
  secret: process.env.CHEELA_RUNTIME_SECRET!,
  nonceStore: redisNonceStore,
});

claim returns false when the nonce has been seen before. It may be sync or async.

Two things that break it

Read the body as text

The signature is over the bytes that were sent. JSON.parse then JSON.stringify will not reproduce them — key order, whitespace and unicode escaping all differ — so the signature can never match. Any middleware that parses the body before your handler has already broken it.

Share the nonce store

Covered above, and worth repeating because it fails silently: with a per-process store and three instances, a replayed request succeeds twice before it starts being rejected.

What the handler returns

STATUSBODYWHEN
200{ output }The capability ran and returned.
200{ output: null, error }The capability threw. Reported rather than raised, so one bad call does not abort the agent run.
400{ error: "invalid_json" }The body was not JSON.
400{ error: "missing_capability" }No capability name in the body.
401{ error: reason }Verification failed. `reason` is one of the six above.
A thrown capability is a 200

Cheela turns the error field into a tool_result the model can read and react to. Returning a 5xx would end the whole execution instead of letting the model recover.

Developing locally

Cheela calls into your endpoint, so it has to be reachable from the internet. Run a tunnel and point the runtime at it:

Terminal
ngrok http 3000
# or: cloudflared tunnel --url http://localhost:3000
cheela.config.ts
endpoint: "https://your-subdomain.ngrok-free.app/cheela/execute",

Then cheela deploy to publish it. The endpoint URL is part of the deployment, so it changes whenever the tunnel does.

http:// is accepted only for localhost, 127.0.0.1 and [::1] — useful for tests that drive the handler in-process, not for a live runtime.

A complete project is the demo storefront — its capabilities call the same repository layer the REST API does, so an agent and a human get identical behaviour. It keeps a without-cheela branch, so diffing that against main shows exactly what serving capabilities added. See also the @cheela/runtime reference for every option.