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
- The charter file
- Your first agent
- Agent fields
- Tools
- Orchestrators
- Budgets
- Guardrails
- Authz for agents
- Calling an agent from your app
- Testing with the mock model
- Switching to Claude
- Editor autocompletion
- Common validation errors
- Limits to know
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:
- Your app. It sends one HTTP or gRPC request to the sidecar on
localhost. It needs no SDK. - The sidecar (
attached). It checks policy, runs the agent, and writes one audit line for every step. - The runtime (
attache-runtime). A Python process. The sidecar starts it as a child process in the same container. The runtime runs the agent loop: it calls the model, and it sends every tool call back to the sidecar.
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:
model: mockselects the built-in test model.get_invoiceis aservicetool.service: billingis this same service, so the sidecar calls your own app at127.0.0.1:8080.{id}in the path is filled from the tool argumentid.authz.callerslets your app (identity: local) call every agent. Without any caller rule, every call is denied with HTTP 403.authz.servicesletsinvoice-helpersendGET /invoices/.... Without this rule,default: denyblocks the tool call.
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:
- The model asked for a tool (
stop=tool_use). - The sidecar allowed
GET /invoices/INV-1001and sent it to your app. The app terminal printsGET /invoices/INV-1001 agent: invoice-helper. - 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:
- The charter goes into a ConfigMap, for example
billing-charterwith the keycharter.yaml. It is mounted at/etc/attache/charter/.kubectl -n shop create configmap billing-charter \ --from-file=charter.yaml=.local/billing/billing.charter.yaml - The sidecar container is written into your Deployment by hand. There is no injection webhook yet. Copy the
attachedcontainer, its volumes and its probes from deploy/inventory/deployment.yaml. - The certificate is the Secret
attache-cert-<service>. Theembassy bootstrapJob creates it. The Job’s--namespaceflag sets both the namespace of the Secrets and the namespace in the SPIFFE id. The Job keeps one CA per namespace, in the Secretattache-ca. The demo Job in deploy/embassy/job.yaml runsbootstrap --namespace attache-demo --services orders,inventory, so addingbillingthere gives a certificate forattache-demo, notshop. Forbillinginshop, run a copy of the Job inshop, with--namespace shop --services billing, and its ServiceAccount, Role and RoleBinding from deploy/embassy/rbac.yaml. The certificates do not rotate. - A charter change needs a pod restart: update the ConfigMap, then run
kubectl -n shop rollout restart deployment/billing.
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:
namematches^[a-zA-Z0-9_-]{1,64}$and is unique within the agent.descriptionmust not be empty. The model reads it to choose the tool.parametersis a JSON Schema object for the tool input. Without it, aserviceorhttptool gets{"type": "object", "properties": {}}, and anagenttool gets one required stringinput.methodis one ofGET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS, in uppercase.
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.
- When
serviceis this charter’s ownservice.name, the sidecar calls your app athttp://127.0.0.1:<app_port>. - When
serviceis another name, the sidecar calls that service’s sidecar over mTLS. That sidecar checks the call again and forwards it to its own app.
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.
urlishttp://orhttps://, a host, an optional port, then a path.{param}may appear only in the path or the query. It may not appear in the scheme, host or port. User info such asuser@hostis not allowed.- The sidecar makes the request, not the runtime. It is audited as
http_call. - The sidecar checks that the agent may use the tool, that the method is the declared method, and that the origin (scheme, host and port) is the declared origin. It does not check the path.
authz.servicesandguardrails.allowed_servicesdo not apply tohttptools.- The sidecar adds
traceparentandx-attache-request-idto the outgoing request.
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.
agentnames the target agent.- Without
service(or with this service’s own name), the target is a local agent. It must be declared in this charter. An agent may not name itself. - With another
service, the target is an agent in that service. Validation cannot check that the remote agent exists. - By default the input is
{"input": "<question>"}. If the arguments have no stringinput, the runtime sends all arguments as one JSON string. - The target agent does not receive the caller’s
context. - The result is the target agent’s final output, after the target’s own output guardrails.
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.
- Each
{name}inpathorurlis replaced by the argument with that name. Names may use letters, digits and_. - The value is percent-encoded.
/becomes%2Fand a space becomes%20. A value cannot add path segments. For aservicetool, the sidecar refuses a path with%2F(guardrail.path). - If an argument is missing, its
{name}is removed. Double slashes become one slash, and a trailing slash is removed. So/stock/{sku}withoutskubecomes/stock. - Arguments that are not used in the path go into the query string for
GET,DELETEandHEAD. For other methods they go into a JSON body. Aservicetool withPOST,PUTorPATCHalways sends a body,{}when there is nothing left. Anhttptool sends no body when there is nothing left. - The request has
accept: application/json, andcontent-type: application/jsonwhen 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:
ask_invoice_helpercallsinvoice-helper, a local agent in the same charter.ask_payment_checkercallspayment-checker, an agent in thepaymentsservice.get_paymentcalls thepaymentsapp directly, through both sidecars.
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:
- The call count includes every invocation of the agent: from your app, from a local orchestrator, and from other services.
- The cost check runs only when an invocation starts. One long invocation can go over the limit.
- The cost comes from the price table in the runtime. The mock model costs 0, so the cost budget never trips on mock. See Switching to Claude.
- Budgets live in the memory of one sidecar process. They reset when the sidecar restarts. Each replica counts on its own, so 3 replicas allow up to 3 times the limit.
- The depth limit (4) is not a charter field. See Depth limit.
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:
- They use the syntax of the Rust
regexcrate. It has no look-around and no back-references. - In YAML double quotes, write
\\for one backslash, as above. - Add
(?i)for a case-insensitive match. - A regex that does not compile is a validation error.
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:
- the agent’s final output that this sidecar returns to the caller, and
- every string in the
inputandoutputof every audit record that this sidecar writes.
It does not rewrite:
- the prompts and tool results that the runtime sends to the model provider,
- the requests that tools send to your app or to external URLs.
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. |
localmeans calls to the app API on127.0.0.1(your app container), and calls from your own orchestrators.- A SPIFFE id means calls from that service’s sidecar over mTLS. The id comes from the caller’s certificate, never from a header.
- An invocation is allowed when one rule has the caller’s identity, lists the agent (or
"*"), and has an emptycaller_agentsor one that contains the calling agent. - There is no default for callers. Without a matching rule, the invocation is denied:
authz.callers.defaultwhen no rule has that identity,authz.callers.<who>when a rule has the identity but does not admit the call.<who>is the service name, orlocal. - This check runs before the agent name is looked up. A peer without a rule cannot learn your agent names.
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:
- Egress, in the calling sidecar, for every
servicetool call, also to your own app. Here the identity islocal, the agent is the calling agent, andserviceis the target. - Inbound, in the target sidecar, for a call from another service. Here the identity is the caller’s SPIFFE id, the agent is the caller’s claim, and
serviceis the target service itself.
How a decision is made:
- A rule applies when its
serviceis the target, itsidentity(if set) equals the caller, and itsagent(if set) equals the calling agent. - If any applying rule has a matching
denypattern, the call is denied (authz.services.<name>). - Else, if any applying rule has a matching
allowpattern, the call is allowed. - Else
authz.defaultdecides:authz.default.denyorauthz.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
- Set
identity: localon rules meant for your own agents. A rule withoutidentityalso applies to inbound calls from other services. If the rule setsagent, it applies when the peer’s calling agent has that name. - Validation does not check
authz.services[].agentorauthz.services[].serviceagainst your agents and services. A typo means the rule never applies, so the call falls toauthz.default. - Your app can read
x-attache-callerandx-attache-agent. Trust them only if nothing but the sidecar can reach your app port. The inventory demo binds its app to127.0.0.1for this reason. No NetworkPolicy is shipped.
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:
- 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 answersMock answer for: <input>and calls nothing. -
Arguments. It fills only properties declared in
parameters:Property Value named input,question,query,prompt,text,messageortaskthe whole input named sku,id,order_id,product_id,item,item_idorcustomer_idthe first token like INV-1001(capital letters, a dash, digits), else the first number with 3 or more digits, else"unknown"if requiredother required integer or number 1other required boolean trueother required property the whole input other optional property left out - 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:
- Put words from your test questions into the tool name or description.
- Name id parameters
id,skuororder_id, and use ids likeINV-1001in test questions. - For an orchestrator, the mock picks one tool with the same word rule. It can be an agent tool or another tool. For an agent tool with the default
parameters, it sends the whole input asinput.
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
- Set
modelto a Claude model id, for exampleclaude-opus-5-5. Validation accepts any non-empty id. A wrong id fails at call time with rulemodel.error(HTTP 502). - Set
ANTHROPIC_API_KEYin the sidecar’s environment before it starts. The runtime is a child process and gets the same environment. On minikube,make secret-anthropicputs the key into the Secretattache-anthropicand restarts the demo pods. - Check
GET /readyz. It shows"model_provider": "anthropic". - Raise
max_tokens_per_callif you seebudget.max_tokens_per_callerrors. Thinking counts against this limit. The demoops-assistantuses 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.
- The charter is loaded once. A change needs a sidecar restart. In Kubernetes, update the ConfigMap and restart the pod.
- No UI. You write charters by hand. The JSON Schema helps your editor.
- Budgets are in memory. They are per sidecar replica and reset on restart.
- The runtime calls the model API directly. Model calls are audited through the sidecar, but they are not proxied. The runtime runs as a child process in the same container as the sidecar.
pii_redactdoes not protect prompts. It cleans the final output and the audit records, not what the model provider receives.- Cross-namespace calls are not supported out of the box. Peer addresses and TLS names use the charter’s own namespace, and the bootstrap Job creates one CA per namespace.
- Sidecar injection is manual. You write the
attachedcontainer into each Deployment. - Certificates are per service. The bootstrap Job issues them, and they do not rotate.
- No NetworkPolicy is shipped. The
x-attache-*headers your app receives are only as trustworthy as your app port is private.