▶ adopter quickstart · v0.1.11 release

Portal — adopter quickstart.

If your service has a URL, an agent can visit it. This page shows both sides of the contract: how to visit a Portal (two curl commands) and how to serve one (two routes and one manifest). Everything below is optional detail.

What is it?

Portal is the minimal HTTP contract for agent-accessible services. Two endpoints. That's the whole protocol. Use Portal when MCP is too heavy and REST is too dumb — stateless drive-by tool calls that any LLM client can make without installing anything on the visitor side.

How to visit a service

A visitor is any process that speaks HTTP. Two requests get you from zero to a tool result:

# 1. Discover — read the manifest
curl https://www.visitportal.dev/portal-static-example.json

# 2. Call — execute a tool
curl -X POST https://www.visitportal.dev/api/portal-static-example/call \
  -H 'content-type: application/json' \
  -d '{"tool":"posts","params":{"limit":3}}'

No client library required. Works from bash, Python urllib, any fetch. A convenience TypeScript SDK (@visitportal/visit) exists and is covered below, but it is strictly optional.

How to make your service visitable

Two route handlers and one manifest. Framework-agnostic pseudocode; every web framework with a request/response API can host a Portal in under twenty lines.

// GET /portal  — serve the manifest
app.get('/portal', (req, res) => {
  res.json({
    portal_version: '0.1',
    name: 'My Service',
    brief: 'What this service does, in plain English.',
    tools: [
      { name: 'ping', description: 'returns pong',
        params: { msg: { type: 'string' } } },
    ],
    call_endpoint: '/portal/call',
    auth: 'none',
    pricing: { model: 'free' },
  });
});

// POST /portal/call  — execute a tool
app.post('/portal/call', async (req, res) => {
  const { tool, params } = req.body;
  if (tool === 'ping') {
    return res.json({ ok: true, result: { pong: true, msg: params?.msg } });
  }
  res.json({
    ok: false,
    error: `tool '${tool}' not in manifest`,
    code: 'NOT_FOUND',
  });
});

This shape works identically in Hono, Express, Fastify, Bun.serve, Cloudflare Workers, Next.js App Router, and FastAPI. The wire contract is the same.

Copyable framework guides: Next.js App Router, Hono, FastAPI, Express, Cloudflare Workers, and static fallback (for sites without a backend).

Provider helper

TypeScript providers can use @visitportal/provider to build a validated manifest and expose both endpoints through one fetch-native handler.

npm i @visitportal/provider

import { serve } from '@visitportal/provider';

const portal = serve({
  name: 'My Service',
  brief: 'One sentence describing what visiting agents can do here.',
  call_endpoint: '/portal/call',
  tools: [
    {
      name: 'ping',
      description: 'returns pong',
      async handler(params) {
        return { pong: true, msg: params.msg ?? null };
      },
    },
  ],
});

export default {
  fetch(request: Request) {
    return portal.fetch(request);
  },
};

Full package docs: packages/provider/ts.

The manifest

A v0.1-conformant manifest is compact. Required keys: portal_version, name, brief, tools[], call_endpoint. Optional: auth, pricing.

{
  "portal_version": "0.1",
  "name": "My Service",
  "brief": "Natural-language description for the visiting LLM.",
  "tools": [
    {
      "name": "ping",
      "description": "returns pong",
      "params": { "msg": { "type": "string" } }
    }
  ],
  "call_endpoint": "/portal/call",
  "auth": "none",
  "pricing": { "model": "free" }
}

Tool params accept the sugar form { type, required?, description? } for the 95% case, or a full JSON Schema 2020-12 object via paramsSchema for the rest. If both are present, paramsSchema wins. call_endpoint can be a root-relative path such as /portal/call, or an absolute https:// URL when the call route is hosted elsewhere. Local development keeps the loopback escape hatch for http://localhost and http://127.0.0.1.

The envelope

Every POST /portal/call takes { "tool": string, "params": object } and returns one of two discriminated-union shapes:

// success
{ "ok": true, "result": { /* tool-defined */ } }

// failure
{ "ok": false, "error": "human-readable message", "code": "NOT_FOUND" }

Error codes

The code field is one of five values; this is the entire surface your visitor needs to understand. HTTP status mapping is normative:

CORS (Appendix C)

For browser-resident visitors, Portal requires a short, normative CORS contract. Both endpoints MUST handle OPTIONS preflight and MUST set Access-Control-Allow-Origin. Credentialed requests have per-auth-mode semantics. See spec Appendix C for the full table.

Rate limiting (Appendix D)

Portal SHOULDs a per-auth-mode default for rate limits. Visitor SDKs MUST treat RATE_LIMITED as recoverable and SHOULD honor Retry-After. Providers without a rate-limit strategy of their own can adopt the defaults verbatim. See spec Appendix D.

SDK (optional)

The SDK is a convenience, not a requirement. Any HTTP client works; the spec is the wire contract. For TypeScript adopters who want typed errors and a one-liner handshake, @visitportal/visit ships a 2.25 kB gzipped, zero-dependency client:

import { visit, CallFailed } from '@visitportal/visit';

const portal = await visit('https://my-service.com/portal');
const result = await portal.call('top_gainers', { limit: 3 });

Error taxonomy: PortalNotFound, ManifestInvalid, ToolNotInManifest, CallFailed. CallFailed.code is typed as the five-code enum above. Python and other-language SDKs follow; the wire contract is the constant.

MCP adapter

If you already have an MCP stdio server, @visitportal/mcp-adapter can expose it as a Portal without rewriting the tool catalog by hand.

npx -p @visitportal/mcp-adapter visitportal-mcp-adapter \
  --mcp "npx some-mcp-server" \
  --port 8080

curl http://127.0.0.1:8080/portal
curl -X POST http://127.0.0.1:8080/portal/call \
  -H 'content-type: application/json' \
  -d '{"tool":"some_tool","params":{}}'

The adapter performs the MCP initialize handshake, reads tools/list, builds a Portal manifest, and forwards calls into tools/call. Full guide: docs/quickstart-mcp-adapter.md.

Conformance

If your service is already exposing GET /portal and POST /portal/call, the shortest path from zero to a pass/fail answer is runSmokeConformance:

npm i @visitportal/spec

import { runSmokeConformance } from '@visitportal/spec';
const report = await runSmokeConformance('https://my-service.com/portal');
console.log(report);

It validates the manifest against the JSON Schema and verifies a NOT_FOUND round-trip on POST /portal/call. Runs in under a second; safe against a live service. For the full offline suite — all 30+ vectors, deterministic, no network — use validateAgainstVectors:

import { validateAgainstVectors } from '@visitportal/spec';
import manifest from './portal.json' assert { type: 'json' };

const report = validateAgainstVectors(manifest);
if (!report.ok) {
  console.error(report.failures);
  process.exit(1);
}

Architecture overview

One page that ties the spec, the SDK, the security model, the AISO + TrendingRepo integration contracts, and the testing plan together — read this if you want the system assembled rather than the parts.

End-to-end agent simulation

Bench the protocol with a real Claude run, not just count_tokens:

# clone the repo, install, run with your own Anthropic key
ANTHROPIC_API_KEY=sk-ant-... pnpm tsx packages/bench/scripts/agent-sim.ts

Spins up a Portal in-process, gives Claude the manifest as Anthropic tools, and runs the tool_use → tool_result loop until end_turn. Mocked unit test runs in CI; live script is opt-in. Source: packages/bench/scripts/agent-sim.ts.

Extensions

Base Portal stays minimal on purpose. Additional capabilities ship as explicitly-versioned Portal Extensions (PE-###), none of which are required for base conformance:

As of v0.1.11, Portal has a stable optional extension for paid tools. Wrap any handler with withPayment() from @visitportal/x402-adapter and the unpaid call returns HTTP 402 with the x402 challenge embedded in the standard Portal envelope. The visiting agent signs a payment per the requirement, retries with X-Payment, and gets the result. Wire-compatible with x402 (Coinbase) and MPP (Cloudflare/Stripe charge intent).

import { serve } from '@visitportal/provider';
import { coinbaseFacilitator, withPayment } from '@visitportal/x402-adapter';

const portal = serve({
  name: 'Premium Echo',
  brief: 'Echo a string back. 0.01 USDC per call.',
  call_endpoint: '/portal/call',
  pricing: { model: 'x402', rate: '0.01 USDC/call' },
  tools: [{
    name: 'premium_echo',
    params: { text: { type: 'string', required: true } },
    handler: withPayment(
      (params) => ({ echoed: params.text as string }),
      {
        price: { scheme: 'exact', network: 'base-sepolia',
                 asset: USDC, amount: '10000', payTo: WALLET },
        facilitator: coinbaseFacilitator(),
      },
    ),
  }],
});

Full walkthrough: docs/quickstart-paid-tools.md. Reference Portal with a paid tool: reference/portal-cf-worker.

Three-layer model

Portal for drive-by visits. MCP for installed tools. A2A for agent coordination. They compose.

TierProtocolUse case
1PortalDrive-by HTTP visits. Stateless. No install.
2MCPInstalled stateful tools.
3A2AMulti-agent coordination.