Skip to the content.

Creating agents

This guide shows how to write a charter, declare agents and tools, run an agent on your own machine, and call it from your app. It also covers orchestrators, budgets, guardrails, authz, tests with the mock model, and the switch to Claude.

Commands and files use the code names: attached is the sidecar, attache is the CLI, embassy is the CA, and charters use apiVersion: attache.io/v1alpha1. Architecture explains the naming. CONTRACT.md is the full reference for every field and rule.

Contents:

How an agent runs

An agent is an entry in your service’s charter file. You do not write agent code. You write YAML.

Three processes take part:

The model never calls your app directly. Every tool call goes to the sidecar first, and the sidecar checks it against the charter.

sequenceDiagram
    participant App as Your app
    participant SC as Sidecar
    participant RT as Runtime
    participant LLM as Model API
    App->>SC: POST /v1/agents/invoice-helper/invoke
    SC->>SC: authz.callers, budgets, input guardrails
    SC->>RT: Execute agent spec and input
    RT->>LLM: system prompt, input, tool list
    LLM-->>RT: tool_use get_invoice
    RT->>SC: tool call get_invoice
    SC->>SC: allowed_tools, allowed_services, authz.services
    SC->>App: GET /invoices/INV-1001
    App-->>SC: 200 JSON
    SC-->>RT: tool result
    RT->>LLM: tool result
    LLM-->>RT: final answer
    RT-->>SC: output
    SC->>SC: output guardrails, pii_redact
    SC-->>App: 200 JSON envelope

The runtime calls the model API itself. The sidecar audits that call, but the call does not go through the sidecar. See Limits to know.

The charter file

Each service has one charter. It holds the service identity, the agents, their tools and budgets, the guardrails, the authz rules, and the audit settings.

The sidecar reads the charter once, at startup. It validates the file and refuses to start when the file is invalid. Every charter object refuses unknown keys, so a typo is an error, not a silent no-op. Only the JSON Schema inside a tool’s parameters is free-form.

These are all the top-level keys. They come from crates/attache-charter/src/lib.rs.

Key Required Default What it holds
apiVersion yes none Must be attache.io/v1alpha1.
kind yes none Must be Charter.
service yes none name, namespace and app_port of this service.
mesh no port: 50002, peers: {} How to reach other services’ sidecars.
agents no [] The agents of this service.
guardrails no see Guardrails Input and output checks, tool and service allow lists.
authz no default: deny, no rules Who may call which agent, and which paths agents may call.
audit no sink: stdout, redact: [] Where audit lines go, and what to hide in them.

Here is a charter that shows every top-level key. Most values are the defaults.

# yaml-language-server: $schema=https://raw.githubusercontent.com/f8fvwgzc/agent-sidecar/main/schemas/charter.schema.json
apiVersion: attache.io/v1alpha1   # required, exactly this value
kind: Charter                     # required, exactly this value
service:                          # required
  name: billing                   # DNS-1123 label; part of the SPIFFE id
  namespace: shop                 # DNS-1123 label; part of the SPIFFE id
  app_port: 8080                  # your app listens on 127.0.0.1:8080
mesh:                             # optional
  port: 50002                     # port used to build peer addresses (default 50002)
  peers: {}                       # optional: service name -> "host:port"
agents:                           # optional, default []
  - name: invoice-helper
    type: agent                   # agent (default) or orchestrator
    model: mock                   # a Claude model id, or mock
    system: You answer questions about invoices.
    tools: []
    budgets:                      # every field is optional; these are the defaults
      max_tokens_per_call: 1024
      max_calls_per_minute: 60
      max_cost_usd_per_day: 10.0
      max_steps: 8
guardrails:                       # optional; these are the defaults
  max_input_bytes: 16384
  pii_redact: false
  input_deny: []
  output_deny: []
  allowed_tools: {}
  allowed_services: {}
authz:                            # optional
  default: deny                   # deny (default) or allow
  callers:
    - identity: local             # your app container
      agents: ["*"]
  services: []
audit:                            # optional
  sink: stdout                    # stdout (default) or file
  redact: []

service

Field Required Rule Meaning
name yes DNS-1123 label Your service name. Tools use this name to target the service.
namespace yes DNS-1123 label The Kubernetes namespace of the service.
app_port yes 1 to 65535 The port of your app. The sidecar calls your app at http://127.0.0.1:<app_port>.

A DNS-1123 label has 1 to 63 characters. It uses only lowercase letters, digits and -. It does not start or end with -.

name and namespace form the identity of the service: spiffe://attache/ns/<namespace>/svc/<name>. The certificate must hold the same identity. If tls.crt holds another identity, the sidecar refuses to start.

mesh

Field Default Meaning
port 50002 The port in the default address of another service: <service>.<namespace>.svc.cluster.local:<port>.
peers {} A map from service name to host:port. It replaces the default address of that service. The local demo uses it, for example inventory: 127.0.0.1:50012.

mesh.port does not change where this sidecar listens. The listen address is the --mesh-addr flag (default 0.0.0.0:50002).

The default address and the TLS server name both use this charter’s own namespace. Cross-namespace calls are not supported out of the box.

audit

Field Default Meaning
sink stdout stdout or file.
file none The file path. Required when sink: file.
redact [] Dotted paths to hide in audit records, for example input.card or output.email. input or output alone hides the whole payload.

Operations explains the audit log in detail.

Your first agent

In this walkthrough you create an agent for an imaginary billing service. The service has one endpoint, GET /invoices/{id}. The agent uses the built-in mock model, so you need no API key.

All files go into .local/billing/ in the repository. Git ignores .local/.

Step 1: build once

You need cargo, protoc, uv, python3 and curl. Getting started has more details.

git clone https://github.com/f8fvwgzc/agent-sidecar.git && cd agent-sidecar
cargo build -p attached -p attache-cli -p embassy
(cd runtime && uv sync)
mkdir -p .local/billing

Step 2: write the app

Your real app can be in any language. For this guide, a small Python server is enough. Save it as .local/billing/billing_app.py:

# billing_app.py: a stand-in for your real service. It knows nothing about agents.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

INVOICES = {
    "INV-1001": {"id": "INV-1001", "status": "paid", "amount_usd": 120.0},
    "INV-1002": {"id": "INV-1002", "status": "overdue", "amount_usd": 75.5},
}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        prefix = "/invoices/"
        invoice = INVOICES.get(self.path[len(prefix):]) if self.path.startswith(prefix) else None
        print("GET", self.path, "agent:", self.headers.get("x-attache-agent"))
        body = json.dumps(invoice or {"error": "not found"}).encode()
        self.send_response(200 if invoice else 404)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


HTTPServer(("127.0.0.1", 8080), Handler).serve_forever()

Start it in its own terminal:

python3 .local/billing/billing_app.py

If port 8080 is busy on your machine, change the port in the app and app_port in the charter. They must be the same.

Step 3: write the charter

Save this as .local/billing/billing.charter.yaml:

apiVersion: attache.io/v1alpha1
kind: Charter
service:
  name: billing
  namespace: shop
  app_port: 8080
agents:
  - name: invoice-helper
    model: mock
    system: |
      You answer questions about invoices. Use get_invoice to read one invoice.
      Answer in one or two sentences.
    tools:
      - name: get_invoice
        description: Get one invoice by its id.
        type: service
        service: billing
        method: GET
        path: /invoices/{id}
        parameters:
          type: object
          properties:
            id: { type: string, description: "Invoice id such as INV-1001" }
          required: [id]
authz:
  default: deny
  callers:
    - identity: local          # the app container may call every agent
      agents: ["*"]
  services:
    - name: self-invoices      # invoice-helper may read invoices from our own app
      identity: local
      agent: invoice-helper
      service: billing
      allow: ["GET /invoices/*"]

What each part does:

Step 4: validate

./target/debug/attache charter validate .local/billing/billing.charter.yaml
valid: .local/billing/billing.charter.yaml (1 agent(s), identity spiffe://attache/ns/shop/svc/billing)

The command exits with 0 when the file is valid. It exits with 1 and prints the errors when it is not. See Common validation errors.

If you did not build the CLI, cargo run -q -p attache-cli -- charter validate <file> does the same.

Step 5: create a certificate

Each sidecar has a certificate with its service identity. Other sidecars use it to know who is calling. For one service alone you do not call anyone over mTLS. You still need a certificate for the sidecar to report “ready”.

./target/debug/embassy ca-init --out .local/billing/ca
./target/debug/embassy cert-issue --ca .local/billing/ca \
  --namespace shop --service billing --out .local/billing/tls

--namespace and --service must match the charter.

Without a certificate, the sidecar still starts. It logs no TLS material in <dir>: mesh disabled, remote calls will fail. Local agents still answer. But GET /readyz stays at 503, because “ready” means the certificate is loaded and the runtime is healthy. In Kubernetes, that pod would never become ready.

Step 6: start the sidecar

Open a second terminal. From the repository root, run:

./target/debug/attached \
  --charter .local/billing/billing.charter.yaml \
  --tls-dir .local/billing/tls \
  --socket-dir "$PWD/.local/billing/run" \
  --mesh-addr 127.0.0.1:50002 \
  --runtime-cmd "uv run --quiet --project $PWD/runtime attache-runtime" \
  > .local/billing/audit.jsonl 2> .local/billing/attached.log
Flag Value here Default Why
--charter your charter /etc/attache/charter/charter.yaml The file to load.
--tls-dir .local/billing/tls /etc/attache/tls Holds ca.crt, tls.crt and tls.key.
--socket-dir .local/billing/run /run/attache The Unix sockets between sidecar and runtime. The default is usually not writable on a laptop.
--mesh-addr 127.0.0.1:50002 0.0.0.0:50002 Keeps the mTLS port on loopback.
--runtime-cmd uv run ... attache-runtime attache-runtime How to start the runtime. The sidecar appends --listen and --sidecar.

The app API keeps its defaults: HTTP on 127.0.0.1:3500 and gRPC on 127.0.0.1:50001. Every flag also has an environment variable, for example ATTACHE_CHARTER. CONTRACT.md §2 lists them all.

The sidecar writes audit lines (JSONL) to stdout. It writes its own log, and the runtime’s output, to stderr. That is why the command sends them to two files.

Wait until the sidecar is ready:

curl -s localhost:3500/readyz
{"ready":true,"runtime":true,"model_provider":"mock","mesh":true}

The runtime needs a few seconds on the first start. Until then you get "ready":false with HTTP 503.

Step 7: call the agent

curl -s localhost:3500/v1/agents/invoice-helper/invoke \
  -H 'content-type: application/json' \
  -d '{"input": "What is the status of invoice INV-1001?"}' | python3 -m json.tool

The answer looks like this. Your ids and latencies will differ.

{
  "status": "ok",
  "request_id": "3d5c2f0e-8a51-4c47-9d0b-6f1f6c3b2a90",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "agent": "invoice-helper",
  "output": "Based on get_invoice: {\"id\": \"INV-1001\", \"status\": \"paid\", \"amount_usd\": 120.0}",
  "usage": { "input_tokens": 130, "output_tokens": 31, "cost_usd": 0.0 },
  "steps": [
    { "kind": "model_call", "name": "mock", "decision": "allow", "latency_ms": 0, "summary": "stop=tool_use" },
    { "kind": "service_call", "name": "billing GET /invoices/INV-1001", "decision": "allow", "latency_ms": 4 },
    { "kind": "model_call", "name": "mock", "decision": "allow", "latency_ms": 0, "summary": "stop=end_turn" }
  ]
}

Read the steps from top to bottom:

  1. The model asked for a tool (stop=tool_use).
  2. The sidecar allowed GET /invoices/INV-1001 and sent it to your app. The app terminal prints GET /invoices/INV-1001 agent: invoice-helper.
  3. The model wrote the final answer (stop=end_turn).

The mock model always answers Based on <tool>: <result>. Testing with the mock model explains how it picks the tool.

Step 8: read the audit log

./target/debug/attache logs --file .local/billing/audit.jsonl

This one call wrote five records, in this order: model_call, tool_call, service_call, model_call, agent_invocation. A record is written when its step ends, so the record of the whole invocation comes last. Add --raw to see the JSON lines. Operations explains the fields and the filters.

Step 9: see default deny

Delete the services: block (the last six lines) from the charter. Then stop the sidecar with Ctrl-C and start it again. The sidecar reads the charter only at startup, so every change needs a restart.

Call the agent again with the same curl command. The status is still ok and HTTP 200, but the service step is now denied:

{ "kind": "service_call", "name": "billing GET /invoices/INV-1001", "decision": "deny", "latency_ms": 1, "rule": "authz.default.deny" }

The output is:

Based on get_invoice: denied by authz.default.deny: no authz.services rule applies for GET /invoices/INV-1001 on billing (agent invoice-helper, caller local)

A denied tool call does not fail the invocation. The model gets the denial as a tool error and can react to it. Your app never received the request.

Put the services: block back and restart the sidecar before you continue.

Also try an agent name that does not exist:

curl -s -o /dev/null -w '%{http_code}\n' localhost:3500/v1/agents/nobody/invoke \
  -H 'content-type: application/json' -d '{"input": "hi"}'

It prints 404. The body has "status": "error" and "rule": "agent.unknown".

Step 10: run it on Kubernetes

The same charter runs in a pod. There, the pieces come from Kubernetes objects:

Getting started runs the full demo on minikube. Microservices shows how to add the sidecar to many services.

Agent fields

Each entry in agents: has these fields.

Field Required Default Rules Meaning
name yes none DNS-1123 label, unique in the charter The name in the URL /v1/agents/<name>/invoke and in authz rules.
type no agent agent or orchestrator Only an orchestrator may declare agent tools.
model yes none not empty A Claude model id, or mock. Validation does not check the id.
system no "" none The system prompt.
tools no [] tool names unique per agent See Tools.
budgets no see Budgets every value > 0 Limits for this agent.

The tool and service allow lists of an agent are not agent fields. They live in guardrails.allowed_tools and guardrails.allowed_services, keyed by the agent name.

One service can have many agents. Monolith and monorepo shows a service with several agents.

Tools

A tool is something the model may ask for. There are three types:

Type What it does Who performs it
service An HTTP request to your own app, or to another service’s app The sidecar. For another service, its sidecar forwards to its app.
http An HTTP request to a fixed external origin The sidecar
agent Runs another agent, in this service or in another service The sidecar

These are the tool fields. “no” means validation rejects the field for that type.

Field service agent http
name required required required
description required required required
type service agent http
parameters optional optional optional
service required optional (omit = local agent) no
method required no required
path required, starts with / no no
agent no required no
url no no required

Common rules:

This charter has one example of each type. The sections below refer to it.

apiVersion: attache.io/v1alpha1
kind: Charter
service:
  name: billing
  namespace: shop
  app_port: 8080
agents:
  - name: invoice-helper
    type: agent
    model: mock
    system: You answer questions about invoices.
    tools:
      # 1. service tool: an HTTP call to a service, through the sidecar
      - name: get_invoice
        description: Get one invoice by its id.
        type: service
        service: billing              # our own service = our own app on 127.0.0.1:8080
        method: GET
        path: /invoices/{id}
        parameters:
          type: object
          properties:
            id: { type: string, description: "Invoice id such as INV-1001" }
          required: [id]
      - name: add_invoice_note
        description: Add a short note to one invoice.
        type: service
        service: billing
        method: POST
        path: /invoices/{id}/notes    # "id" fills the path; "note" goes into the JSON body
        parameters:
          type: object
          properties:
            id: { type: string }
            note: { type: string }
          required: [id, note]
      # 2. http tool: a call to an external origin, made by the sidecar
      - name: get_exchange_rate
        description: Get today's exchange rate from USD to another currency.
        type: http
        method: GET
        url: https://api.example.com/v1/rates/{currency}
        parameters:
          type: object
          properties:
            currency: { type: string, description: "ISO code such as EUR" }
          required: [currency]
  - name: billing-assistant
    type: orchestrator                # only orchestrators may declare agent tools
    model: mock
    system: You help the billing team. Ask the other agents for facts.
    tools:
      # 3a. agent tool, local: another agent in this charter
      - name: ask_invoice_helper
        description: Ask the invoice helper agent about one invoice.
        type: agent
        agent: invoice-helper
      # 3b. agent tool, remote: an agent in the payments service
      - name: ask_payment_checker
        description: Ask the payments service whether an invoice was paid.
        type: agent
        agent: payment-checker
        service: payments
      # a service tool that targets another service
      - name: get_payment
        description: Get the payment record of one invoice from the payments service.
        type: service
        service: payments
        method: GET
        path: /payments/{id}
        parameters:
          type: object
          properties:
            id: { type: string }
          required: [id]
authz:
  default: deny
  callers:
    - identity: local                    # the app may call only the orchestrator
      agents: [billing-assistant]
    - identity: local                    # invoice-helper is internal:
      agents: [invoice-helper]           # only billing-assistant may call it
      caller_agents: [billing-assistant]
  services:
    - name: self-invoices
      identity: local
      agent: invoice-helper
      service: billing
      allow: ["GET /invoices/*", "POST /invoices/*"]
    - name: egress-payments
      identity: local
      agent: billing-assistant
      service: payments
      allow: ["GET /payments/*"]

Service tools

A service tool sends method and path to a service.

The sidecar checks each call in this order. The first failing check denies it.

Check Rule name when denied
The agent may use this tool guardrail.allowed_tools
The call has the declared service and method guardrail.tool_spec
The agent may call this service guardrail.allowed_services
The path is valid. . and .. segments are resolved first. A path with whitespace, a control character, \, #, or an encoded /, \ or NUL is refused. guardrail.path
An authz.services rule allows the method and path, for identity: local authz.services.<rule> or authz.default.deny

For another service, that service’s sidecar then checks the path and its own authz.services again, with your identity. Both charters must allow the call.

The sidecar does not compare the path with the path template. The template only tells the runtime how to build the request. authz.services is the rule that limits paths. Always write allow patterns for your service tools.

Your app receives these headers from the sidecar:

Header Value
x-attache-caller local for your own agents, or the caller’s SPIFFE id for a call from another service
x-attache-agent The name of the calling agent
x-attache-request-id The request id of the whole invocation
traceparent A W3C trace header, a child span of the invocation

The sidecar sets these values itself. It drops any value for them that comes from the runtime or from a peer.

HTTP tools

An http tool calls a URL outside your services, for example a public API.

A tool has no field for request headers or credentials. If an API needs a key, put a small endpoint in your app that adds the key, and call it with a service tool.

Agent tools

An agent tool runs another agent. Only an agent with type: orchestrator may declare one. See Orchestrators.

How arguments become a request

The runtime turns the model’s tool arguments into a request. The code is runtime/src/attache_runtime/tools.py.

  1. Each {name} in path or url is replaced by the argument with that name. Names may use letters, digits and _.
  2. The value is percent-encoded. / becomes %2F and a space becomes %20. A value cannot add path segments. For a service tool, the sidecar refuses a path with %2F (guardrail.path).
  3. If an argument is missing, its {name} is removed. Double slashes become one slash, and a trailing slash is removed. So /stock/{sku} without sku becomes /stock.
  4. Arguments that are not used in the path go into the query string for GET, DELETE and HEAD. For other methods they go into a JSON body. A service tool with POST, PUT or PATCH always sends a body, {} when there is nothing left. An http tool sends no body when there is nothing left.
  5. The request has accept: application/json, and content-type: application/json when it has a body.

With the charter above:

The model calls The request
get_invoice with {"id": "INV-1001"} GET /invoices/INV-1001 on your app
add_invoice_note with {"id": "INV-1001", "note": "called customer"} POST /invoices/INV-1001/notes with body {"note": "called customer"}
get_exchange_rate with {"currency": "EUR"} GET https://api.example.com/v1/rates/EUR
get_invoice with {"id": "../admin"} path /invoices/..%2Fadmin, denied with guardrail.path

Validation does not check that every {param} is declared in parameters. Always declare them. Without a declared property, the model has no field to fill, and the mock model fills only declared properties.

What the model sees

For each tool the agent may use, the model sees only three things: name, description, and the input schema (parameters or its default). It does not see service, method, path or url. It does not see tools that guardrails.allowed_tools removes.

The first user message is your input. When you send a context, the runtime adds it as a JSON block:

What is the status of invoice INV-1001?

<context>
{"customer":"c-1"}
</context>

A tool result is the response body as text, up to 20,000 characters. It is marked as an error when the HTTP status is 400 or higher. A denied call returns denied by <rule>: <reason>. A failed call (for example, the app is down) returns tool failed: .... When the model asks for several tools in one turn, the runtime runs them one after another.

Orchestrators

An orchestrator is an agent with type: orchestrator. It may declare agent tools, so its tools can be other agents. It may also have service and http tools.

In the tools charter above, billing-assistant is an orchestrator:

flowchart LR
    App["billing app"] --> BA["billing-assistant (orchestrator)"]
    BA -->|"ask_invoice_helper (local)"| IH["invoice-helper"]
    IH -->|"get_invoice"| BApp["billing app: GET /invoices/..."]
    BA -->|"ask_payment_checker (mTLS)"| PC["payment-checker in payments"]
    PC -->|"get_payment"| PApp["payments app: GET /payments/..."]
    BA -->|"get_payment (mTLS)"| PApp

Local agents

A local agent call goes through the full pipeline again: authz.callers, budgets, input guardrails, the run, output guardrails. The caller is local and the calling agent is the orchestrator.

The tools charter uses this to keep invoice-helper internal. The first caller rule lets the app call only billing-assistant. The second rule lets local call invoice-helper only when billing-assistant is the calling agent. A direct call from the app to invoice-helper gets HTTP 403 with rule authz.callers.local.

Remote agents

The remote service needs its own charter that allows the call. Here is the payments charter:

apiVersion: attache.io/v1alpha1
kind: Charter
service:
  name: payments
  namespace: shop                     # same namespace as billing
  app_port: 8080
agents:
  - name: payment-checker
    model: mock
    system: You check whether an invoice was paid.
    tools:
      - name: get_payment
        description: Get the payment record of one invoice.
        type: service
        service: payments
        method: GET
        path: /payments/{id}
        parameters:
          type: object
          properties:
            id: { type: string }
          required: [id]
authz:
  default: deny
  callers:
    - identity: local
      agents: ["*"]
    - identity: spiffe://attache/ns/shop/svc/billing     # the billing pod ...
      agents: [payment-checker]                          # ... may run this agent ...
      caller_agents: [billing-assistant]                 # ... only when billing-assistant asks
  services:
    - name: self-payments
      identity: local
      agent: payment-checker
      service: payments
      allow: ["GET /payments/*"]
    - name: billing-reads-payments
      identity: spiffe://attache/ns/shop/svc/billing
      agent: billing-assistant
      service: payments
      allow: ["GET /payments/*"]
    - name: no-admin
      identity: spiffe://attache/ns/shop/svc/billing
      service: payments
      deny: ["* /admin", "* /admin/*"]

When billing-assistant uses ask_payment_checker, these checks run:

Where Check
billing sidecar billing-assistant may use the tool, and the call matches the declaration (guardrail.allowed_tools, guardrail.tool_spec).
billing sidecar payments is in the agent’s allowed services (guardrail.allowed_services). By default it is, because a tool names it.
billing sidecar It opens mTLS to the payments sidecar with the billing certificate. It sends the claim “calling agent = billing-assistant”.
payments sidecar authz.callers: the certificate identity spiffe://attache/ns/shop/svc/billing may run payment-checker, and billing-assistant is in caller_agents.
payments sidecar Depth, the budgets of payment-checker, and the input guardrails of the payments charter. Then the agent runs.
payments sidecar Each tool call of payment-checker is checked by the payments charter, with identity: local.
payments sidecar Output guardrails and pii_redact of the payments charter. Then the output goes back to billing.

The caller’s identity comes only from its certificate. The calling agent name is a claim from the caller’s sidecar. The payments sidecar trusts that pair exactly as far as its own rules say.

For the remote service tool get_payment, authz.callers is not used. The billing charter must allow it (egress-payments), and the payments charter must allow it (billing-reads-payments). A deny match always wins, so no-admin blocks /admin for every billing agent.

Depth limit

Agent calls can nest: an orchestrator can call an orchestrator. The sidecar counts the depth. The call from your app has depth 0, and each agent tool call adds 1. The sidecar refuses depth above 4 with rule budget.max_depth. The calling model gets this as a denied tool call.

So the chain app → A → B → C → D → E works, and E calling another agent is denied. Validation blocks an agent that names itself, but not a loop such as A → B → A. The depth limit stops such loops.

The usage of an invocation counts only the agent’s own model calls. The tokens of a sub-agent appear in the agent_call audit record, and they count against the sub-agent’s own budgets.

Budgets

Each agent has its own budgets. All four fields are optional.

Field Default Valid values Checked by What happens
max_tokens_per_call 1024 1 to 128000 runtime Sent as max_tokens on every model call. Thinking counts against it. If the model stops at the limit with text and no tool call, the result is ok with the cut text. Otherwise the result is error with rule budget.max_tokens_per_call (HTTP 502).
max_calls_per_minute 60 > 0 sidecar, before the run Invocations of this agent in a sliding 60-second window. One more is denied with rule budget.max_calls_per_minute (HTTP 429).
max_cost_usd_per_day 10.0 > 0 sidecar, before the run The sum of cost_usd of this agent’s model calls in the current UTC day. When the sum reaches the limit, new invocations are denied with rule budget.max_cost_usd_per_day (HTTP 429).
max_steps 8 > 0 runtime Model calls per invocation. If the agent has no final answer after that many, the result is error with rule budget.max_steps (HTTP 502).

Things to know:

Guardrails

Guardrails are charter-wide checks. max_input_bytes, pii_redact, input_deny and output_deny apply to every agent in the charter. allowed_tools and allowed_services are set per agent.

Field Default Meaning Rule name when it acts
max_input_bytes 16384 Limit on the size of input plus the context JSON, in bytes. guardrail.max_input_bytes (HTTP 403)
input_deny [] List of {name, regex}. Checked against the input and the context JSON. A match denies the invocation before any model call. guardrail.input_deny.<name> (HTTP 403)
output_deny [] List of {name, regex}. Checked against the agent’s final output. A match denies the invocation, and the output is not returned. guardrail.output_deny.<name> (HTTP 403)
pii_redact false Replaces emails with [redacted:email] and phone numbers with [redacted:phone]. guardrail.pii_redact (decision stays allow)
allowed_tools every declared tool Map from agent name to tool names. Other tools are removed before the model sees the list. The sidecar also refuses calls to them. guardrail.allowed_tools
allowed_services every service its tools name Map from agent name to service names. Checked for service tools and remote agent tools. guardrail.allowed_services
apiVersion: attache.io/v1alpha1
kind: Charter
service:
  name: billing
  namespace: shop
  app_port: 8080
agents:
  - name: invoice-helper
    model: mock
    system: You answer questions about invoices.
    tools:
      - name: get_invoice
        description: Get one invoice by its id.
        type: service
        service: billing
        method: GET
        path: /invoices/{id}
        parameters:
          type: object
          properties:
            id: { type: string }
          required: [id]
      - name: void_invoice
        description: Cancel one invoice.
        type: service
        service: billing
        method: POST
        path: /invoices/{id}/void
        parameters:
          type: object
          properties:
            id: { type: string }
          required: [id]
guardrails:
  max_input_bytes: 8192             # input + context, in bytes
  pii_redact: true                  # emails and phone numbers in the final output and in audit records
  input_deny:
    - name: no-secrets
      regex: "(?i)(api[_-]?key|password|secret)\\s*[:=]"
  output_deny:
    - name: no-card-numbers
      regex: "\\b\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}\\b"
  allowed_tools:
    invoice-helper: [get_invoice]   # void_invoice stays declared, but the model never sees it
  allowed_services:
    invoice-helper: [billing]
authz:
  default: deny
  callers:
    - identity: local
      agents: ["*"]
  services:
    - name: self-invoices
      identity: local
      agent: invoice-helper
      service: billing
      allow: ["GET /invoices/*"]

Rules for the regexes:

allowed_tools and allowed_services can only narrow the defaults. Each tool you list must be declared by that agent. Each service you list must be the target of one of its tools.

What pii_redact covers

pii_redact rewrites emails and phone numbers in two places:

It does not rewrite:

So with Claude, a tool result that holds an email reaches the model provider unchanged. Only the final answer and the audit log are clean. Keep data out of tool responses if the model provider must not see it.

Tool results are not checked by input_deny or output_deny. Those rules see only the invocation input and the final output.

Authz for agents

authz has two lists. callers says who may invoke which of your agents. services says which methods and paths agents may call.

authz.callers

Field Required Meaning
identity yes local, or a SPIFFE id such as spiffe://attache/ns/shop/svc/billing.
agents yes, not empty Agent names of this charter, or "*" for all.
caller_agents no Calling agents this rule admits. Empty means any caller, including a direct call from the app.

authz.services

Field Required Meaning
name yes A unique rule name. It appears in denials as authz.services.<name>.
identity no The caller: local or a SPIFFE id. Omit for any caller.
agent no The calling agent. Omit for any agent.
service yes The target service.
allow at least one of allow / deny Patterns that allow.
deny at least one of allow / deny Patterns that deny.

The rules are checked in two places:

How a decision is made:

  1. A rule applies when its service is the target, its identity (if set) equals the caller, and its agent (if set) equals the calling agent.
  2. If any applying rule has a matching deny pattern, the call is denied (authz.services.<name>).
  3. Else, if any applying rule has a matching allow pattern, the call is allowed.
  4. Else authz.default decides: authz.default.deny or authz.default.allow.

authz.default applies only to authz.services. It does not open authz.callers.

Patterns

A pattern is "<METHOD> <path>". METHOD is one HTTP method or * for any method. The path starts with /. It may end with *, and only the end. The path is cleaned before matching (/a/../admin becomes /admin), and the query string is ignored.

Pattern Matches Does not match
GET /invoices GET /invoices, GET /invoices?page=2 GET /invoices/INV-1, POST /invoices
GET /invoices/* GET /invoices/INV-1, GET /invoices/INV-1/notes GET /invoices
* /admin /admin with any method /admin/reset
* /admin/* /admin/reset with any method /admin
* /admin* /admin/reset, /administrator /admin

To cover a path and everything below it, write two patterns: "* /admin" and "* /admin/*". Not supported: * in the middle (/invoices/*/notes), lists of methods, and regexes.

Tips

Calling an agent from your app

HTTP

POST http://127.0.0.1:3500/v1/agents/<name>/invoke

curl -s localhost:3500/v1/agents/invoice-helper/invoke \
  -H 'content-type: application/json' \
  -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
  -d '{"input": "What is the status of invoice INV-1001?", "context": {"customer": "c-1"}}'

The request body:

Field Required Type Meaning
input yes string The question or task.
context no any JSON, usually an object Extra data for the model. It is checked by the input guardrails too.

Request headers: send content-type: application/json. Send a W3C traceparent to join your own trace. Without it, or with an invalid value, the sidecar starts a new trace.

The response body:

Field Meaning
status ok, denied or error.
request_id A new UUID for each call from the app. Every audit record of this call has it, in every service.
trace_id The W3C trace id.
agent The agent name.
output The final answer. Empty when status is not ok.
usage input_tokens, output_tokens and cost_usd of this agent’s own model calls.
steps One entry per step: kind, name, decision, latency_ms, and rule and summary when set.
rule, reason Only when status is not ok. Which rule acted, and why.

Response headers: traceparent and x-attache-request-id.

Status codes:

HTTP status Example rule When
200 ok none The agent finished. Denied tool calls inside the run still give 200.
400 error request.invalid The body is not JSON, or has no input string. This body has no output, usage or steps.
403 denied authz.callers.default, guardrail.input_deny.no-secrets, guardrail.output_deny.<name>, model.refusal The invocation itself was refused.
404 error agent.unknown No such agent. You get it only after authz.callers passed.
429 denied budget.max_calls_per_minute, budget.max_cost_usd_per_day A budget is used up.
502 error runtime.unavailable, model.error, budget.max_steps, budget.max_tokens_per_call The run failed.

Other endpoints on the same port:

Endpoint Returns
GET /v1/agents {"agents": [{"name", "type", "model", "tools"}]}. tools lists only the allowed tools.
GET /v1/charter The loaded charter as JSON, with the defaults filled in (for example budgets).
GET /healthz 200 ok while the process runs.
GET /readyz 200 when the certificate is loaded and the runtime is healthy, else 503. Body: ready, runtime, model_provider, mesh.

gRPC

The same API is a gRPC service on 127.0.0.1:50001, plaintext. The definition is in proto/attache/v1alpha1/app.proto, with the shared messages in common.proto.

Method Request Response
attache.v1alpha1.AppApi/InvokeAgent InvokeAgentRequest InvokeAgentResponse
attache.v1alpha1.AppApi/ListAgents Empty ListAgentsResponse

Set agent, input and, if you want, context_json (a JSON text, not an object) and traceparent. A traceparent in the gRPC metadata wins over the field. The sidecar sets request_id, caller_agent and depth itself and ignores what the app sends.

The server has no reflection, so a tool like grpcurl needs the proto files:

grpcurl -plaintext -import-path proto -proto attache/v1alpha1/app.proto \
  -d '{"agent": "invoice-helper", "input": "What is the status of invoice INV-1001?"}' \
  127.0.0.1:50001 attache.v1alpha1.AppApi/InvokeAgent

When status is ok, the response is the InvokeAgentResponse message with the same fields as the HTTP JSON. Otherwise the call fails with a gRPC status, and you get no envelope:

Case gRPC status
denied by authz, a guardrail or a model refusal PERMISSION_DENIED
denied by a budget.* rule RESOURCE_EXHAUSTED
unknown agent NOT_FOUND
other errors UNAVAILABLE

The status message is <rule>: <reason>. The trailing metadata has x-attache-rule and x-attache-request-id.

Testing with the mock model

The mock model is deterministic and works offline. Use it in local runs and in your own CI. This repository does not ship a CI config. Its code is in runtime/src/attache_runtime/models.py.

When is the mock used?

ANTHROPIC_API_KEY in the sidecar’s environment model: mock model: <Claude id>
not set mock mock
set mock Claude

GET /readyz shows "model_provider": "mock" or "anthropic", depending only on the key.

How the mock decides:

  1. First turn, no tool results yet. It lowercases the input and splits it into words. A word is a run of letters and digits, at least 3 characters long, that is not a stop word (for example “the”, “what”, “how”, “get”, “service”, “agent”, “tool”). It does the same for each tool’s name plus description. _ in a tool name splits words. It picks the one tool that shares the most words. A tie goes to the tool declared first. If no tool shares a word, it answers Mock answer for: <input> and calls nothing.
  2. Arguments. It fills only properties declared in parameters:

    Property Value
    named input, question, query, prompt, text, message or task the whole input
    named sku, id, order_id, product_id, item, item_id or customer_id the first token like INV-1001 (capital letters, a dash, digits), else the first number with 3 or more digits, else "unknown" if required
    other required integer or number 1
    other required boolean true
    other required property the whole input
    other optional property left out
  3. Next turn, with tool results. It answers Based on <tool>: <result>, with each result cut to 600 characters. It never calls a second tool.

Token counts are estimates (characters divided by 4). The cost is always 0.

Tips for deterministic tests:

A test can call the agent and check the steps. This one fails when the service call is missing or denied:

curl -s localhost:3500/v1/agents/invoice-helper/invoke \
  -H 'content-type: application/json' \
  -d '{"input": "What is the status of invoice INV-1001?"}' \
| python3 -c '
import json, sys
d = json.load(sys.stdin)
assert d["status"] == "ok", d
assert any(s["kind"] == "service_call" and s["name"] == "billing GET /invoices/INV-1001"
           and s["decision"] == "allow" for s in d["steps"]), d["steps"]
print("ok")'

Test denials the same way. Check for a step with "decision": "deny" and the rule you expect. scripts/local-demo.sh does this for the demo.

Switching to Claude

  1. Set model to a Claude model id, for example claude-opus-5-5. Validation accepts any non-empty id. A wrong id fails at call time with rule model.error (HTTP 502).
  2. Set ANTHROPIC_API_KEY in the sidecar’s environment before it starts. The runtime is a child process and gets the same environment. On minikube, make secret-anthropic puts the key into the Secret attache-anthropic and restarts the demo pods.
  3. Check GET /readyz. It shows "model_provider": "anthropic".
  4. Raise max_tokens_per_call if you see budget.max_tokens_per_call errors. Thinking counts against this limit. The demo ops-assistant uses 2048.

The runtime streams every request to the model API. It sends output_config.effort: low to every model except ids that start with claude-haiku-4-5, claude-sonnet-4-5, claude-opus-4-1, claude-opus-4-0, claude-sonnet-4-0 or claude-3.

The cost budget uses the price table in runtime/src/attache_runtime/pricing.py. The prices are USD per 1 million tokens, as written in that file:

Model id Input Output
claude-fable-5-1 10.00 50.00
claude-fable-5 10.00 50.00
claude-opus-5-5 4.00 20.00
claude-opus-5 5.00 25.00
claude-opus-4-8 5.00 25.00
claude-opus-4-7 5.00 25.00
claude-opus-4-6 5.00 25.00
claude-sonnet-5-5 2.00 10.00
claude-sonnet-5 2.00 10.00
claude-sonnet-4-6 3.00 15.00
claude-haiku-5-5 0.10 (0.50 above 100K input tokens) 0.50 (2.50 above 100K input tokens)
claude-haiku-4-5 1.00 5.00
mock 0 0
any other id 10.00 50.00

An unknown id gets the highest price, so the budget never counts too little. This table only feeds the budget. Your real bill comes from Anthropic.

Editor autocompletion

The JSON Schema of the charter is schemas/charter.schema.json. The Rust types generate it (attache charter schema), and a test keeps the file in sync.

Editors that use yaml-language-server (for example the YAML extension for VS Code) read a $schema comment at the top of the file. Point it to the schema in the repository:

# yaml-language-server: $schema=https://raw.githubusercontent.com/f8fvwgzc/agent-sidecar/main/schemas/charter.schema.json

A relative path also works, for example $schema=../schemas/charter.schema.json. The path is relative to the charter file.

The editor then completes field names and marks unknown fields and wrong types. The schema does not hold every rule. For example, it does not check DNS labels, the orchestrator rule or regexes. Always run attache charter validate before you deploy.

Common validation errors

attache charter validate prints errors in two forms.

A parse error stops at the first problem and shows the line and column:

invalid: billing.charter.yaml
charter is not valid YAML or has unknown fields: guardrails: unknown field `pii_redaction`, expected one of `max_input_bytes`, `pii_redact`, `input_deny`, `output_deny`, `allowed_tools`, `allowed_services` at line 36 column 3

When the file parses, every rule is checked, and all errors are listed at once:

invalid: billing.charter.yaml
charter is invalid:
  - agents[0] (invoice-helper).tools[0] (ask_other): only type: orchestrator agents may declare agent tools
  - agents[0] (invoice-helper).tools[0] (ask_other): local agent "ghost" is not declared in this charter

Fix parse errors first, then run the command again.

Message (part) Meaning Fix
unknown field `x`, expected one of ... A key is misspelled or does not exist. Use a key from the list.
missing field `model` A required field is missing. Add it.
found a tab character that violates indentation or found character that cannot start any token YAML does not allow tabs for indentation. Use spaces.
apiVersion must be attache.io/v1alpha1 / kind must be Charter Wrong header. Copy the header from an example.
... is not a DNS-1123 label Uppercase letters, _ or other characters in a service, namespace or agent name. Use lowercase letters, digits and -.
duplicate agent name / duplicate tool name Two agents, or two tools of one agent, share a name. Rename one.
tool name must match ^[a-zA-Z0-9_-]{1,64}$ Spaces, dots or other characters in a tool name. Use letters, digits, _ and -.
every budget must be > 0 A budget is 0 or negative. Use a positive value, or remove the field to get the default.
budgets.max_tokens_per_call must be <= 128000 The token limit is too high. Use 128000 or less.
service tools need service, method and path A service tool misses one of them. Add the missing field.
path must start with / A tool path is relative. Start it with /.
http tools need method and url An http tool misses one of them. Add the missing field.
url must be http(s)://host[:port]/… with no {param} in the scheme, host or port The URL has a {param} in the host, user info, or another scheme. Fix the origin. Put parameters only in the path or query.
method "get" is not an HTTP method Lowercase or unknown method. Use GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS.
only type: orchestrator agents may declare agent tools An agent tool on a plain agent. Set type: orchestrator.
local agent "x" is not declared in this charter A local agent tool names a missing agent. Fix the name, or add service: for a remote agent.
an agent may not call itself An agent tool targets its own agent. Target another agent.
guardrails.allowed_tools.x: tool "y" is not declared The allow list names a tool the agent does not have. Fix the name.
no tool of this agent targets service "x" allowed_services names a service no tool of the agent uses. Remove it, or add a tool for it.
guardrails regex rule #0 (name): regex parse error A regex does not compile. Fix the regex. Remember \\ in double quotes.
identity must be `local` or a spiffe:// URI A wrong identity in authz.callers or authz.services. Use local or spiffe://attache/ns/<ns>/svc/<svc>.
agents must not be empty / unknown agent "x" A caller rule has no agents, or names an agent that is not in this charter. List agents of this charter, or "*".
name must be unique and non-empty Two authz.services rules share a name. Rename one.
needs allow or deny patterns A service rule has no patterns. Add allow or deny.
bad pattern ... only a trailing * is supported A * in the middle of a path. Use a prefix with a trailing *.
bad pattern ... is not an HTTP method or * A wrong method in a pattern. Use an uppercase method or *.
audit.sink: file requires audit.file sink: file without a path. Add audit.file.

Limits to know

These are true for the current code. Operations has the full list.