Publish a service
Make an API you already run discoverable and callable by agents, in about twenty minutes.
You need two static JSON files and one auth check. Your routes, handlers, validation and error shapes stay exactly as they are.
1. Verify agent tokens
Agents send a JWT signed by their own key. You verify it against a key set you fetch from their broker.
// lib/auth.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
const AUDIENCE = "popcorn.club";
// One cached key set per broker. Brokers are stable, so this is a small map.
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
export async function authenticate(req: Request) {
const token = req.headers.get("authorization")?.replace(/^Bearer /, "");
if (!token) return null;
// The issuer names the broker, so there is no list to configure.
const { iss } = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString(),
);
if (!iss?.startsWith("interagentic://")) return null;
const { host, pathname } = new URL(iss.replace("interagentic:", "https:"));
const jwksUrl = `https://${host}${pathname}/jwks.json`;
let jwks = jwksCache.get(jwksUrl);
if (!jwks) {
jwks = createRemoteJWKSet(new URL(jwksUrl));
jwksCache.set(jwksUrl, jwks);
}
const { payload } = await jwtVerify(token, jwks, {
audience: AUDIENCE,
maxTokenAge: "5m",
});
return { subject: payload.sub as string };
}Decide which brokers you trust before you go to production. Accepting any
interagentic:// issuer means accepting identities from a broker anyone can
run. For most services an allowlist of one is correct.
Then use it:
// app/api/v1/orders/route.ts
import { authenticate } from "@/lib/auth";
export async function POST(req: Request) {
const caller = await authenticate(req);
if (!caller) return new Response("Unauthorized", { status: 401 });
const { qty } = await req.json();
const order = await createOrder(qty, caller.subject);
return Response.json(order);
}2. Publish the index
{
"name": "Popcorn Club",
"description": "Popcorn, delivered.",
"version": "1",
"auth": "interagentic",
"baseUrl": "/api/v1",
"groups": [
{ "command": ["orders"], "description": "Place and track orders" }
],
"actions": [
{
"command": ["orders", "list"],
"endpoint": "/orders",
"method": "GET",
"description": "List your orders",
"role": "user",
"payment": "free"
},
{
"command": ["orders", "create"],
"endpoint": "/orders",
"method": "POST",
"description": "Place an order",
"role": "user",
"payment": "fixed"
}
]
}Write the descriptions the way you would write CLI help, because that is what they become.
3. Publish one file per action
Each action gets a file at a path mirroring its command:
{
"command": ["orders", "create"],
"endpoint": "/orders",
"method": "POST",
"description": "Place an order",
"role": "user",
"payment": { "mode": "fixed", "amount": 9.0 },
"args": ["qty"],
"params": [
{
"name": "qty",
"label": "Quantity",
"description": "How many bags to send",
"example": 2,
"required": true,
"type": { "type": "integer", "minimum": 1 }
}
],
"example": {
"input": { "qty": 2 },
"output": { "id": "ord_8fJ2", "status": "confirmed" }
}
}There is no request object here, and none is needed: without one, a POST
sends its arguments as a JSON body under their own names — { "qty": 2 } —
which is what this handler already reads.
When your API's shape does not match its arguments, map them with request templates instead of changing the API:
{
"endpoint": "/orders/{{$id}}/refunds",
"request": {
"query": { "notify": "{{$notify}}" },
"body": { "reason": "{{$reason}}", "refund": { "amount": "{{$amount}}" } }
}
}If you already publish OpenAPI, generate these files instead of writing them. The mapping is mechanical:
| OpenAPI | Manifest |
|---|---|
| path + method | command — POST /orders/{id}/refunds is orders refunds create |
x-interagentic-command | command, when you want a different name |
{id} path parameter | {{$id}} in endpoint, and a required id argument |
summary | description |
requestBody.schema | params[].type |
required[] | params[].required |
example | example.input |
Only pricing and the human-facing labels have to be written by hand.
4. Check it
Lint the manifest in the playground, then call it for real:
npx interagentic popcorn.club
npx interagentic popcorn.club orders create --qty 25. Optional — charge for it
Return 402 with a price and an approval link:
if (!(await hasApproval(caller.subject, 9.0))) {
return Response.json(
{
error: "payment_required",
asset: "interagentic-credit",
amount: "9.00",
description: "2 × Classic popcorn",
approveUrl: approvalUrl(caller.subject, 9.0),
},
{
status: 402,
headers: { "X-Payment": "interagentic-credit; amount=9.00" },
},
);
}See the Credits protocol for the full contract.
6. Optional — answer in markdown
Clients append ?humanReadable=true unless asked for raw output. Honouring it
makes your service pleasant in a terminal without a second API:
const url = new URL(req.url);
if (url.searchParams.get("humanReadable") === "true") {
return new Response(renderMarkdown(orders), {
headers: { "Content-Type": "text/markdown" },
});
}
return Response.json(orders);