project 08 — ~60 min
Put a syndicate behind your own website
How an agent becomes an endpoint your app calls
by the end you can:
- Serve an agent graph over a protocol, and read its agent card → taught in 2.01
- Silo memory per end user with an id your backend sets, and erase it on request → taught in 2.02
- Keep every secret server-side, and treat a request as input the loop must validate → taught in 2.03
- Control the input stage: decide what enters the window before the model reads it → taught in 1.01
you need: Node 22+ and the course repo · Gemini key (free) · An app with its own login, or a scratch script standing in for one
Every syndicate we have run so far executed within a local terminal process. A syndicate definition can also run as an HTTP service: npm run start:a2a exposes the graph as an Agent-to-Agent JSON-RPC endpoint accompanied by a machine-readable card declaring its capabilities. In this project, you host a syndicate as a network service and wire the endpoint into your own web application. You will configure the request boundary so authenticated users interact with the agent under isolated memory partitions.
The transport layer is JSON-RPC 2.0 delivered over standard HTTP POST requests, meaning a basic fetch call implements the entire client. The endpoint remains secure because the bearer secret and inference API keys remain isolated inside your backend environment rather than traveling to the client browser. The user identifier that partitions long-term memory is also assigned strictly by your application session rather than supplied by untrusted user input. You will generate the authentication secret, boot the server, inspect the agent card, transmit a manual probe, execute the reference client, and implement the server-side proxy function. By the end of this exercise, your backend will route user requests to a running syndicate while keeping every application’s session records under its own key hash.
the request passes through your backend, never around it
When a visitor signed in to your site submits a question, answering it requires three values: a bearer secret to reach the endpoint, an inference key to pay for model calls, and a user identifier under which conversational memory will be filed. A browser can be inspected by its user, which means none of the three credentials can live on the client. Furthermore, allowing the visitor to supply the user identifier directly would let one visitor query another user’s record. Where each credential lives, and who sets the identifier, determines the security boundary of the entire system.
The browser communicates exclusively with your application backend over your established session. After completing its own authentication checks, your backend sets three headers containing the bearer secret, your inference key, and the verified account identifier, then posts one JSON-RPC call to the agent service. The server verifies the bearer secret, enforces rate limits, constructs the runner for that request, and files memory under a2a-{keyhash}/{X-User-Id}. The same YAML workflow that ran in your terminal answers the query.
One statement about this page’s figure, made once. The figure draws one round trip. A conversation reuses one contextId per thread, and the [STATUS] events the server streams while it delegates are not drawn.
step one: generate the secret that gates every request
The server authenticates inbound HTTP requests using the bearer secret. Generate a 32-byte cryptographic random value:
openssl rand -hex 32
Store the generated string in .env under the key A2A_SERVER_SECRET. These 64 hexadecimal characters must be transmitted in the Authorization: Bearer header on every incoming request. When configured for public deployment, the server refuses to initialize if this variable is missing. During local development with the variable unset, the server logs a warning and accepts unauthenticated connections. That fallback simplifies local testing, but it represents an operational vulnerability on a public interface. Enforce the boundary deterministically: system prompts cannot protect an open port, so security relies on deterministic header verification before any model invocation takes place.
step two: start the server with a syndicate
npm run start:a2a -- config/agents/examples/patient_advocate.yaml
This command initializes an Express server on port 4000, or the port defined by $PORT, hosting the specified syndicate graph. On startup, the process logs provider key availability, the status of long-term memory, and whether the database has undergone production hardening. For your initial test, run a stateless syndicate such as syndicate.yaml. You can transition to the advocate configuration once you complete the memory project and establish a hardened database schema.
Inspect the published agent card:
curl http://localhost:4000/.well-known/agent-card.json
The server returns a JSON manifest specifying the agent’s name, description, service endpoint URL, and declared capabilities. External callers parse this card to evaluate routing suitability. In a multi-agent graph, a sub-agent’s description carries its delegation interface: the routing criteria the orchestrator evaluates. The agent card exposes that identical interface over HTTP, allowing external client applications outside your local file tree to discover and call the service.
step three: send one message by hand
Every message is one JSON-RPC call to message/send requiring three headers:
curl -X POST http://localhost:4000/a2a/jsonrpc \
-H "Authorization: Bearer $A2A_SERVER_SECRET" \
-H "X-API-Key: $GOOGLE_GENAI_API_KEY" \
-H "X-Provider: google" \
-H "X-User-Id: scratch-8f3a2c" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"messageId": "req-001",
"role": "user",
"parts": [{ "kind": "text", "text": "In one sentence, what can you do?" }]
},
"contextId": "scratch-thread-1"
}
}'
The Authorization header proves caller access against the bearer secret. The X-API-Key header provides the upstream inference credential: the server maintains no shared inference key, ensuring token usage and billing remain attributed directly to the caller account. The X-Provider header acts as a fallback selector when a syndicate configuration omits an explicit provider, though any model: property declared in your YAML configuration takes precedence. The X-User-Id header provides the isolated partition key for memory.
Within the JSON-RPC request body, messageId uniquely identifies the individual message packet, while contextId tracks the multi-turn conversational thread. Passing an existing contextId instructs the server to retrieve prior context and resume the established conversation.
The response payload arrives as a JSON-RPC result object containing text parts emitted by the syndicate. During execution, the server streams [STATUS] events detailing sub-agent delegations and tool invocations, providing real-time visibility into the agent loop. Execute the reference client script to inspect the complete network exchange:
node demo/a2a_demo.mjs
This script requires thirty lines of fetch. It reads A2A_SERVER_SECRET and your API key from .env, dispatches the JSON-RPC payload, and logs the parsed result to stdout. Use this script as the architectural foundation for your production backend integration.
step four: the user id is the silo, and your backend owns it
A shared memory store must keep two operational cases strictly separated. First, two different applications might each register a user named alice. Second, a visitor to a single application might type bob into an input field to read Bob’s record. What must the storage key be built from to prevent both cross-application leakage and cross-user impersonation?
The server enforces this boundary by placing every fact and session inside a silo keyed as a2a-{hash of the API key}/{X-User-Id}. The hash of the API key separates distinct applications, ensuring that two services with identical user identifiers cannot cross-read or mutate each other’s database rows. The user id separates individuals inside a single application. Because the server treats the user id as authoritative, your backend must set that identifier only after completing its own login flow, rather than accepting unverified client parameters or arbitrary strings. The server validates the user id against an allowlist of alphanumeric characters and rejects non-conforming values with an HTTP 400 status.
The server takes the id as given inside the caller’s key hash. Authenticate your user before you put their id in the header. A site that lets visitors pick their own id has defeated its own siloing, and no prompt downstream can repair that.
When an account requires data deletion, your backend issues an HTTP DELETE request to /memory, authenticating with the bearer secret alongside the API key and the user id:
curl -X DELETE http://localhost:4000/memory \
-H "Authorization: Bearer $A2A_SERVER_SECRET" \
-H "X-API-Key: $GOOGLE_GENAI_API_KEY" \
-H "X-User-Id: scratch-8f3a2c"
This request deletes every extracted fact within that silo and returns the count of purged records from the memory table. Conversational session transcripts reside in a second store; executing a complete user purge requires removing their corresponding transcript rows as well. You can verify this deletion step with your scratch identifier before binding real user accounts to the system.
step five: the one function your app needs
Regardless of your backend framework, integration requires exactly one server-side proxy function. This function encapsulates your bearer secret and inference keys within the server environment, preventing credential leakage to client browsers:
// server-side only — the secret and the key stay in this process's environment
const A2A_URL = process.env.A2A_URL; // e.g. https://your-server/patient_advocate/a2a/jsonrpc
export async function askAgent(userId, text, contextId) {
const res = await fetch(A2A_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.A2A_SERVER_SECRET}`,
'X-API-Key': process.env.BACKEND_GEMINI_KEY,
'X-Provider': 'google',
'X-User-Id': userId, // your app's opaque account id, set after login
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'message/send',
params: {
message: { messageId: crypto.randomUUID(), role: 'user', parts: [{ kind: 'text', text }] },
contextId,
},
}),
});
if (!res.ok) throw new Error(`A2A ${res.status}`);
return res.json();
}
Your application’s route handler authenticates incoming user requests, invokes askAgent using the verified account identifier and thread identifier, and returns the unpacked text parts to the client. The browser communicates exclusively with your backend endpoint, remaining entirely decoupled from the agent server. When directing automated code generation tools to integrate this layer, enforce these two invariants: read secrets strictly from backend environment variables, and verify that X-User-Id maps to an internally verified account identifier on every request.
Verify this isolation using synthetic test fixtures before connecting live traffic: initialize two distinct user identifiers across two separate thread contexts, store a distinct fact under the first identifier, and assert that subsequent queries under the second identifier return no trace of that data. That deterministic assertion verifies partition integrity within your automated test suite.
step six: deploy behind the checks the server enforces
Assigning PUBLIC_URL to your production host domain triggers strict operational validation checks during startup. The server terminates immediately if A2A_SERVER_SECRET is not set. If the syndicate enables long-term memory, the server also refuses to start until the database schema has been verified as hardened, logging an explicit error unless an operator explicitly passes a declared override flag. The server enforces a baseline rate limit of sixty task submissions per fifteen minutes per IP address. This threshold provides a safeguard against runaway client loops rather than production traffic shaping, so enforce fine-grained, identity-aware rate limits inside your own backend application. Deployment requires standard Node execution honoring the $PORT environment variable, such as the five-command sequence documented in the quickstart.
Two architectural constraints govern this deployment model. First, an inbound API key subsidizes calls to a single upstream provider. If a syndicate routes tasks to sub-agents configured on alternate providers like grok-* or claude-*, the Melchizedek server must resolve those secondary credentials from its own environment. Multi-provider teams therefore require credential configuration directly on the server host. Second, plan-dispatch syndicates execute exclusively within this server runtime: while the terminal runner compiles static delegation hierarchies, the HTTP endpoint provides the runtime environment that parses and executes dynamic dispatch: blocks.
equip yourself with the materials
- The integration kit: the env lines, the start command, the curl, the
askAgentfunction, the erasure call, and the deploy checklist: a2a-website-integration.md. - The setup document whose section 4 this project expands, with the paste-ready prompt for a coding agent: melchizedek-agent-setup.md.
- The modules: serving the graph, siloing and erasure.
apply it to your own ecosystem
Select an active interface in your application architecture where a user submits queries: documentation search, form validation assistance, or an internal operational panel. Deploy an integrated syndicate behind that interface:
- Define a targeted syndicate configuration for that specific operational scope, or adapt an existing reference configuration, testing in your terminal until three successive evaluation runs pass deterministically.
- Launch the service, verify the published agent card, and transmit a manual test request using a synthetic scratch identifier.
- Implement
askAgentin your application backend behind your authentication layer, passing the opaque account identifier generated by your database. - Execute boundary verification across two distinct identifiers: store one fact, assert zero state leakage across the silo boundary, and verify the memory erasure route. Set
PUBLIC_URLand confirm that startup fails if mandatory production safeguards are absent.
The measurement after one week comprises two numbers your backend can log: the number of turns each user recorded, and the count of [STATUS] events that invoked a tool. That second metric reveals whether model outputs were grounded in external tool results or generated entirely from static weights, providing empirical verification for every fluent response.