Dome Systems

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.

Open in Cursor

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-hr connection, the redact-contact Filter, the hr-assistant agent, and the tool rule bundle.
  • Call a model through a pool completed. This application needs the employee-summary pool and the hr-assistant-llm rule so Chat can invoke the model.
  • Node.js 18 or later.

Confirm you are still on the sandbox before you start:

dome context current

The workspace should read sandbox-get-started. If it does not, switch back:

dome context use sandbox-get-started

This 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 service

The 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 install

Fill .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:

.env
DOME_TOKEN=dome_...
DOME_GATEWAY_URL=https://GATEWAY_HOST/gateways/DEFAULT_GATEWAY_ID
DOME_POOL=employee-summary
dome context current
dome gateways list

This 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.

  1. UI → your backend. The client posts a user message (or a direct tool click) to your server, never to the Gateway.
  2. Backend → model (pool). The server POSTs OpenAI-shaped chat completions to …/gateways/<id>/v1/chat/completions, with model set to the pool name (employee-summary), not a vendor model id. The agent token stays in server env.
  3. Model may request tools. If the completion includes tool_calls, the server maps each short name to a qualified MCP tool and POSTs JSON-RPC tools/call to …/gateways/<id>/mcp.
  4. 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.
  5. 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).
  6. 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.

src/App.tsx
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.

server/index.ts
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.

server/llm.ts
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.

server/agent.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.

server/dome.ts
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).

server/agent.ts
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.

src/App.tsx
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;
  // ...
}
server/index.ts
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 dev

Open 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:

  1. A model step that named employee-summary (open View gateway call on that step to confirm "model": "employee-summary").
  2. A tool step for get_employee with contact fields already [REDACTED].
  3. 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.

ActionStatusWhat to notice
List employeesAllowedDemo employees, including E001.
Who is E001?Allowed · redactedAlice Johnson's record with "email": "[REDACTED]".
What is Alice's salary?DeniedA 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:

ClickWhat Dome did
List employeesCedar 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 20

You should see, for this run:

  1. model.call at stage=attempted and stage=completed for Chat turns through employee-summary.
  2. tool.call at stage=attempted and stage=completed for list_employees (and any tools the model invoked).
  3. guard.filter.evaluate for get_employee, which is the Filter reporting what it changed.
  4. A completed tool.call for get_salary with result=denied and the shared denial block.

To read only the rejection:

dome audit query --results denied --limit 10

Both 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:

Refer to the following docs for topics covered in this lab:

  • Develop to authenticate and call Gateways from your application
  • Guards to inspect and filter request and response content

On this page

Was this page helpful?