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:
NOT_FOUND— tool name isn't in the manifest · HTTP 404INVALID_PARAMS— params failed validation · HTTP 400UNAUTHORIZED— caller lacks credentials · HTTP 401RATE_LIMITED— transient; visitors SHOULD retry afterRetry-After· HTTP 429INTERNAL— anything else · HTTP 500
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.
- docs/architecture-overview.md — 9-section system overview
- docs/integrations/aiso-readiness-score.md — Portal Readiness Score (5 × 20-point rubric) for AISO scanners
- docs/integrations/trendingrepo-portal-badge.md — "Portal Ready" badge contract for TrendingRepo
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.tsSpins 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:
- PE-001 — streaming responses (draft)
- PE-002 — paid tools via HTTP 402 +
x402/ MPP (stable, v0.1.11 · spec: docs/pe-002-paid-tools.md, adapter: @visitportal/x402-adapter, quickstart: quickstart-paid-tools.md)
Paid tools (PE-002)
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.
| Tier | Protocol | Use case |
|---|---|---|
| 1 | Portal | Drive-by HTTP visits. Stateless. No install. |
| 2 | MCP | Installed stateful tools. |
| 3 | A2A | Multi-agent coordination. |