Build a governed app
Wire model and tool calls in one app. Watch allow, redact, and deny.
Tool calls and inference are what an agent actually does. The application puts both on one path: the model names the employee-summary pool, may call tools through the same gateway, and every step stays governed, including allow, redact, and deny.
Hand this to an AI agent. It runs the HR application so you see model + tools together, then allow, redact, and deny.
In this tutorial, you will run a small HR application against the Gateway you already set up. The model calls employee-summary and may invoke tools. Dome governs every allow, redact, and deny, and records each step in the audit trail.
The walkthrough uses the Vite + Hono demo-hr-desk reference app.
To do this, you will:
Mint a credential for the app
Issue a second key on the existing agent identity.
Set up the project
Clone the reference app and fill .env.
Understand the agent flow
See the generic loop, then the app files that implement it.
Run the agent loop
Chat through the pool and watch model and tool steps.
Show allow, redact, and deny
Click the three tool actions on the Tools tab.
Verify the results
Match what you saw against the audit trail.
Prerequisites
For this tutorial, you will need:
- The Dome CLI. Refer to Install for Homebrew and direct-download instructions.
- Govern your first agent completed, with its sandbox still active. That tutorial created the
demo-hrconnection, theredact-contactFilter, thehr-assistantagent, and the tool rule bundle. - Call a model through a pool completed. This application needs the
employee-summarypool and thehr-assistant-llmrule so Chat can invoke the model. - Node.js 18 or later.
Confirm you are still on the sandbox before you start:
dome context currentThe workspace should read sandbox-get-started. If it does not, switch back:
dome context use sandbox-get-startedThis tutorial runs entirely in a sandbox. In a production workspace, minting agent credentials is a developer action, while the rules this app runs under are owned by security.
Mint a credential for the app
An agent identity can hold several credentials. Your editor already has one. The app needs its own, so you can revoke either without disturbing the other.
dome agents create-key hr-assistant --name serviceThe token is shown once. Copy the Token: dome_… value. Both keys resolve to hr-assistant, so both are governed by the same rules and both appear in audit under the same agent.
Set up the project
Clone the reference HR application, change into the directory, create a new .env file, then install the dependencies.
git clone https://github.com/dome-systems/demo-hr-desk.git
cd demo-hr-desk
cp .env.example .env
npm installFill .env from the CLI. The gateway URL must include the /gateways/<id> segment and must not include /mcp or /v1. The Hono proxy appends those itself:
DOME_TOKEN=dome_...
DOME_GATEWAY_URL=https://GATEWAY_HOST/gateways/DEFAULT_GATEWAY_ID
DOME_POOL=employee-summarydome context current
dome gateways listThis tutorial uses plain HTTP so the wire shape stays visible.
However, you can use any OpenAI-compatible client, AI SDK, or MCP client at the Gateway URL with the agent token. Chat completions go to …/gateways/<id>/v1, and tools go to …/gateways/<id>/mcp.
How a governed agent works
Any app that puts a model and tools behind Dome follows the same shape. The frontend never calls Dome. The agent token is a bearer credential for the Gateway. If it lives in the browser, anyone who opens DevTools can call tools and models as that agent. Keep the token on your backend and have the UI talk only to your server.
- UI → your backend. The client posts a user message (or a direct tool click) to your server, never to the Gateway.
- Backend → model (pool). The server
POSTs OpenAI-shaped chat completions to…/gateways/<id>/v1/chat/completions, withmodelset to the pool name (employee-summary), not a vendor model id. The agent token stays in server env. - Model may request tools. If the completion includes
tool_calls, the server maps each short name to a qualified MCP tool andPOSTs JSON-RPCtools/callto…/gateways/<id>/mcp. - Tools return through Dome. Allow, redact, and deny happen on that MCP path before your code sees the result. Denied calls become structured errors you feed back to the model.
- Loop. Tool results go into the message list. The server calls the pool again until the model returns plain text (or you hit a round limit).
- Optional direct tools. A Tools UI can hit your backend's
/api/tool, which then calls/mcp, with still no browser → Dome path. Useful for proving allow / redact / deny in isolation.
Dome manages authorization, filtering, and audit. Your application manages the agent loop: what to ask the model, which tools to attempt, and how to show the results.
How this application implements it
demo-hr-desk is the reference application for this tutorial. The sections below are a guided tour of how it implements each step above. Skip ahead to Run the agent loop if you want to try it first.
UI → your backend
src/App.tsx posts conversation history to /api/chat. The agent token never leaves the server.
async function sendChat(prompt: string) {
// ...
const messages = [...prior, { role: "user" as const, content: text }];
// ...
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
const result = (await res.json()) as AgentResponse;
// ...
}server/index.ts mounts the backend routes. Only these handlers (and the helpers they call) may use DOME_TOKEN or reach the Gateway.
app.post("/api/chat", async (c) => {
// ... build messages from body.messages or body.prompt
const result = await runAgent({
messages,
pool: body.pool,
});
return c.json(result);
});Backend → model (pool)
server/llm.ts is the curl from Call a model through a pool in TypeScript: Gateway + /v1/chat/completions, bearer agent token, model = pool.
export async function chatCompletion(options: {
prompt?: string;
messages?: ChatMessage[];
pool?: string;
tools?: unknown[];
}): Promise<CallTrace> {
const base = process.env.DOME_GATEWAY_URL;
const token = process.env.DOME_TOKEN;
const pool = options.pool || process.env.DOME_POOL || DEFAULT_POOL;
const requestBody: Record<string, unknown> = {
model: pool,
messages,
};
if (options.tools?.length) {
requestBody.tools = options.tools;
requestBody.tool_choice = "auto";
}
// ...
const url = `${base.replace(/\/$/, "")}/v1/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
// ...
}Model may request tools
When the completion includes tool_calls, server/agent.ts maps each short name to a qualified MCP tool and calls server/dome.ts.
export const TOOL_MAP: Record<string, string> = {
list_employees: "demo-hr/hr/list_employees",
get_employee: "demo-hr/hr/get_employee",
get_salary: "demo-hr/finance/get_salary",
// ...
};
for (const call of toolCalls) {
const shortName = call.function?.name ?? "";
const mcpName = TOOL_MAP[shortName] ?? shortName;
// ... parse arguments
const toolTrace = await callTool(mcpName, args);
// ...
}Tools return through Dome
server/dome.ts posts JSON-RPC tools/call to /mcp. Allow, redact, and deny happen on that path before your code sees the result.
export async function callTool(
name: string,
args: Record<string, unknown> = {},
): Promise<CallTrace> {
const base = process.env.DOME_GATEWAY_URL;
const token = process.env.DOME_TOKEN;
const requestBody = {
jsonrpc: "2.0" as const,
id: 1,
method: "tools/call" as const,
params: { name, arguments: args },
};
// ...
const url = `${base.replace(/\/$/, "")}/mcp`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
// ... map body.error → denied, result → allowed / error
}Loop
server/agent.ts runs the rounds. Tool results go into the message list. The server calls the pool again until the model returns plain text (or you hit a round limit).
export async function runAgent(options: {
messages: ChatMessage[];
pool?: string;
}): Promise<AgentResult> {
// ... seed system prompt + user messages
for (let round = 0; round < MAX_ROUNDS; round++) {
const trace = await chatCompletion({
messages: messages as ChatMessage[],
pool: options.pool,
tools: OPENAI_TOOLS,
});
steps.push({ type: "model", trace });
// ... return early if denied / no tool_calls
for (const call of toolCalls) {
const shortName = call.function?.name ?? "";
const mcpName = TOOL_MAP[shortName] ?? shortName;
// ... parse arguments
const toolTrace = await callTool(mcpName, args);
steps.push({ type: "tool", name: shortName, mcpName, arguments: args, trace: toolTrace });
messages.push({
role: "tool",
tool_call_id: call.id,
content: toolResultContent(toolTrace),
});
}
}
// ...
}Each steps entry is what the Chat transcript and View gateway call sheet show: model requests naming employee-summary, tool requests naming demo-hr/….
Optional direct tools
A Tools UI can hit /api/tool for one MCP call without the model loop. Still no browser → Dome path. Useful for proving allow, redact, and deny in isolation.
async function runTool(action: (typeof TOOL_ACTIONS)[number]) {
// ...
const res = await fetch("/api/tool", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: action.name, arguments: action.args }),
});
const trace = (await res.json()) as CallTrace;
// ...
}app.post("/api/tool", async (c) => {
const body = await c.req.json<{
name?: string;
arguments?: Record<string, unknown>;
}>();
// ...
const trace = await callTool(body.name, body.arguments ?? {});
return c.json(trace);
});That is the whole app: UI → your backend → pool and/or MCP. The frontend never calls Dome. The agent token never leaves the server.
Run the agent loop
Start the application:
npm run devOpen http://localhost:5173. Stay on Chat (the default). Use Try asking and start with Who is E001 and how do I reach them?
You should see:
- A model step that named
employee-summary(open View gateway call on that step to confirm"model": "employee-summary"). - A tool step for
get_employeewith contact fields already[REDACTED]. - A final assistant reply that uses the tool result.
Try What is Alice's salary? next. The model may attempt get_salary. The gateway denies it, and the transcript shows the refusal, the same Cedar rule as a direct tool click, now inside the loop.
Show allow, redact, and deny
Chat mixed allow, redact, and deny into the model loop. The Tools tab isolates each outcome with one click, so you can see which control fired without the model choosing the tools.
Open Tools and click each button in order. Your application does not decide who may call which tool, and it does not mask fields. You already defined those controls on the Gateway. Your code only receives the allowed, redacted, or denied result.
| Action | Status | What to notice |
|---|---|---|
| List employees | Allowed | Demo employees, including E001. |
| Who is E001? | Allowed · redacted | Alice Johnson's record with "email": "[REDACTED]". |
| What is Alice's salary? | Denied | A reason from the rule that matched, not an empty payload. |
The second result is the important one for Guard Filters. The backend returned a real address. The redact-contact Guard Filter replaced it on the response path before your proxy deserialized it. The page never asked for masking and cannot switch it off.
Each result lists the outcome. Click View gateway call to open the side sheet with the outbound request and gateway response (token redacted).
Why each click behaved that way:
| Click | What Dome did |
|---|---|
| List employees | Cedar allowed mcp:call on demo-hr/hr/list_employees. |
| Who is E001? | Cedar allowed the call. The redact-contact Guard Filter masked email on the response. |
| What is Alice's salary? | Cedar denied mcp:call on demo-hr/finance/get_salary because it is outside the allowlist. |
Verify the results
The outcomes you saw are also on the record, attributed to the agent rather than to your process.
dome audit query --limit 20You should see, for this run:
model.callatstage=attemptedandstage=completedfor Chat turns throughemployee-summary.tool.callatstage=attemptedandstage=completedforlist_employees(and any tools the model invoked).guard.filter.evaluateforget_employee, which is the Filter reporting what it changed.- A completed
tool.callforget_salarywithresult=deniedand the shareddenialblock.
To read only the rejection:
dome audit query --results denied --limit 10Both credentials resolve to one identity, so nothing here distinguishes this app from your editor. That is deliberate: the rules and the record follow the agent, not the process that holds the key. When you want them separated in audit, register a second agent rather than a second key.
Next steps
You learned how to run model and tool calls from one application through the same agent and Gateway. Keep the sandbox-get-started workspace for the next tutorial. Continue with:
- Govern per end user to authorize from the person, not only the agent
Refer to the following docs for topics covered in this lab: