Charter
The easiest way to build and manage production-ready agents.
Pre-alpha, and in the open early. The design is settled enough to read and argue with; the code is not settled enough to run anything you care about. Expect the configuration format to change.
A prototype agent is a prompt and some tools. A production agent needs more: a defined responsibility, explicit authority over what it may touch, a budget it can't exceed, a way for humans to intervene while it works, and someone watching its behavior over time.
Charter is that, as configuration. You describe an agent in YAML — what it's responsible for, which tools it gets, what needs a human, what it may spend — and Charter deploys it as a durable, governed service you can operate.
The agent runs in your environment. Its state, policy and history live in a control plane, so a task survives a closed laptop, a restarted worker, or an approval that takes until tomorrow.
[!WARNING] Pre-alpha. Charter agents have run end-to-end against a live control plane, a real model and real MCP servers — but plenty hasn't. Expect rough edges and expect the configuration format to change.
Define an agent
An agent is a directory with a version file in it — refund-triage/v1.yaml:
apiVersion: charter/v1
kind: AgentConfig
name: refund-triage
version: 1
model: claude-haiku-4-5
objective: |
Resolve the refund request on ticket {{ inputs.ticket_id }}.
Look up the ticket and the charge before proposing anything.
Never propose a refund above ${{ inputs.max_refund_usd }}.
inputs:
ticket_id: { type: string, required: true }
max_refund_usd: { type: number, default: 100 }
mcp:
- name: stripe
url: https://mcp.stripe.com
env: [STRIPE_API_KEY]
tools:
- tool: get_charge
- tool: create_refund
approval: always
response_format:
resolution: { type: string }
refunded_usd: { type: number }
That file is the whole agent. Budgets and lifecycle rules live in two optional files beside it — see a project — and a conservative default ceiling applies until you add them.
Deploy it, create an instance, and give it work:
charter agent create refund-triage
charter apply .
charter run refund-triage --instance a3f9c012 --ticket-id 4821
Suppose the agent reads the ticket and the charge and concludes a $240 refund is
warranted. It cannot issue it. create_refund requires approval, so Charter
parks the task and surfaces the proposed action and the agent's reasoning to a
human:
charter approve apr_01J8Z --reason "third dispute this month"
Only then is the refund executed. The agent receives the result, finishes the task, and reports what happened. The task didn't live in your terminal in the meantime.
What makes it production-ready
Explicit authority
Access to an MCP server is not access to everything that server exposes. The model only ever sees the tools you declared:
mcp stripe: 34 tools available, 2 declared (32 ignored)
If the server adds a tool tomorrow, your agent doesn't silently gain a capability.
And tools marked approval: always aren't callable inside the agent loop at all —
the agent can propose them, but a separate step executes them after a human signs
off. The model can propose authority it doesn't have; it can't grant it to itself.
A budget per task
per_run:
max_cost_usd: 0.30
max_llm_calls: 40
max_tool_failures: 3
Limits hold across the whole task — reasoning rounds, retries, human feedback and tool calls alike. When one is exhausted, Charter tells you the operational reason rather than collapsing it into a generic failure:
stripe__create_refund failed 3 times (max_tool_failures=3)
the integration looks broken
Humans in the loop, durably
An agent stops for a human for two reasons: it needs information it shouldn't guess, or it wants to do something beyond its authority.
charter pending refund-triage
charter approve apr_01J8Z --reason "confirmed duplicate"
charter answer inp_01J8Z "use the March charge"
Neither is a process sitting around waiting. Charter checkpoints the task; it can wait overnight and resume on another worker with everything it had discovered, spent and been told. Rejection carries a reason back to the agent, so a "no" is feedback it can act on, not just a closed door.
Policy that acts on its own
Humans govern individual actions. Lifecycle rules govern the agent itself:
rules:
- when: { metric: num_failures, threshold: 2 }
then: { pause: { window: 5 } }
- when: { metric: cost, threshold: 5.00 }
then: { cooldown: { window: 20, seconds: 300 } }
- when: { metric: approval_rejections, threshold: 3 }
then: { set_version: { target: 1 } }
A noisy agent gets cooled down. A repeatedly failing one gets paused. A new version whose decisions keep getting rejected rolls back to the one that worked — and because versions are immutable specifications, rollback restores the whole agent, not just a prompt string. What changed is a file in Git, with an author and a diff.
Operate agents, not sessions
An agent persists beyond any single task.
$ charter agents
AGENT VER STATUS ACTIVITY
invoice-chaser v1 paused idle
refund-triage v1 active awaiting_approval
ticket-sweeper v1 active idle
Inspect its authority and how close it is to tripping a rule:
$ charter describe refund-triage
limits per task
max_cost_usd 0.25
max_llm_calls 20
rules
num_failures 1 of 2 -> pause window=5
cost 5.2 of 5 -> cooldown window=20 seconds=300
And reconstruct, afterwards, every governance decision that was made:
$ charter audit refund-triage
2026-08-18 03:47 approval rejected by dana@example.com
refund-triage: run stripe__create_refund
amount_usd: 240
reason: wrong charge — ch_9001 is the original
Getting started
- Define the agent —
v1.yaml: its objective, the tools it may call, which of them need a human. - Set its policy —
runtime.yamlfor budgets and authority,lifecycle.yamlfor pause, cooldown and rollback rules. - Package it —
charter pushseals the version into your registry. Skip it while developing: a worker reads a directory just as well. - Configure a worker —
worker.yaml: credentials, and which agents and versions this process serves. - Apply it —
charter applyarms config and policy on the control plane;charter agent createinstantiates the agent. - Run it —
charter runstarts a task;charter statussays how it went. - Manage it — the step that doesn't end: approve what it proposes, watch what
it spends, add a
v2.yamlwhen it should behave differently.charter uiputs the same thing in a browser, for whoever decides an approval.
pip install boundflow-charter # add [ui] for the console, [otel] for traces
pip install --pre boundflow-charter # or whatever main is, published every green build
A control plane
Charter needs one to run agents against. To run one locally:
docker compose -f deploy/local.compose.yml up -d --wait
docker compose -f deploy/local.compose.yml run --rm server -mode=provision -name=me
That prints an API key. With it:
export BOUNDFLOW_API_KEY=<the key it printed>
export BOUNDFLOW_SERVER_ADDRESS=http://localhost:50051
export BOUNDFLOW_WORKER_ADDRESS=http://localhost:50052
export CHARTER_STORE_URL=postgres://charter:charter@localhost:5434/charter
Remove it with docker compose -f deploy/local.compose.yml down -v.
For production, either run the BoundFlow backend yourself — its
deployment docs
own that — or use BoundFlow Cloud, managed and in early access
(request access). Cloud hands you an API key and the
two addresses; export those instead of the local ones and nothing else changes.
The worker still runs wherever you put it, so CHARTER_STORE_URL is still yours.
Whichever you pick, inference stays yours: the control plane never sees your model key or its traffic.
A project
.
├── worker.yaml # which agents this worker serves, pricing, channels
└── leads-finder/
├── v1.yaml # objective, tools, gates — versioned, immutable
├── v1/skills/ # procedures for that version, shipped with it
├── runtime.yaml # budgets, limits, authority — policy, not versioned
└── lifecycle.yaml # pause, cooldown and rollback rules
Only v1.yaml is required; a version file plus credentials is enough to run an
agent. The split matters: v1.yaml is what the agent does and is versioned, so
a rollback restores behavior. Budgets and lifecycle rules are today's guardrails
and stay in force across one.
Skills are the procedures the agent should follow once it gets there — how your
refunds policy works, the runbook a new hire would be handed. Drop them in
v1/skills/<name>/SKILL.md and they ship with that version; the layout is
deepagents' own, so skills you already have work unchanged. They live inside v1/
because rolling back to v1 should restore the instructions v1 was running with.
Traces
Every model call and tool call, with its prompts, results and token counts, goes to a sink the worker owns:
trace_sink:
kind: otel
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT}
otel speaks OTLP and follows OpenTelemetry's GenAI conventions, so Jaeger, Tempo,
Datadog and Langfuse all read it without a translation layer — jsonl and
logging are there for when you just want to look. Traces are captured worker-side
and carry prompts, so they go to your backend and never to the control plane.
That is separate from store.url, which holds checkpoints and the agent's files —
state a parked task resumes from, not telemetry.
Which agents a worker serves
serves:
- agent: leads-finder # from ./leads-finder, while you develop
versions: [1]
- agent: refund-triage # from the registry, once it is real
versions: [1, 2]
repository: ghcr.io/acme/agents
A repository derives one address per version — <repository>/<agent>:v<N>, the
address charter push writes. List every version a lifecycle rule can roll back
to: a worker that cannot build the old version leaves the control plane
dispatching work nobody can handle.
Credentials
worker.yaml is committed, so nothing secret goes in it. Credentials are ${VAR}
references resolved from the environment when the worker starts:
control_plane:
endpoint: ${BOUNDFLOW_SERVER_ADDRESS} # the control API, for the CLI
worker_endpoint: ${BOUNDFLOW_WORKER_ADDRESS} # where workers claim tasks
api_key: ${BOUNDFLOW_API_KEY}
tenant: default
llm:
provider: anthropic
api_key: ${ANTHROPIC_API_KEY}
store:
url: ${CHARTER_STORE_URL}
Two addresses, because BoundFlow serves the control API and worker dispatch
separately — on two ports locally, and on two hosts in Cloud. Leave
worker_endpoint out and workers fall back to BOUNDFLOW_WORKER_ADDRESS, then to
localhost, which is what you want while developing and never what you want against
a remote control plane.
Inference is bring-your-own: your model key stays in the worker environment and model traffic never reaches the control plane. The CLI reads this same file, so the control plane is configured once and every command talks to the one your worker does.
First run
charter validate . # parse and cross-check every file
charter agent create leads-finder # bring one instance into existence
charter apply . # arm config, policy and pricing
charter worker . # in its own terminal — this is the process
create is separate from apply because an instance owns state — its own store,
budget and lifecycle history — so bringing one into existence is a decision a
person makes, not something CI does on their behalf. apply is safe to re-run as
often as you like.
create prints an instance id; keep it. Commands that act on an agent name an
instance, because the instance is the thing holding the state:
charter run leads-finder --instance a3f9c012 --topic "..."
Deploying
Locally, charter worker . is the whole thing, which is what you want while
iterating — the agent's MCP servers and your logs are right in front of you.
For anything long-lived, run the worker as a container. The reason is isolation rather than packaging: an agent can declare MCP servers as commands, and the worker executes them. deploy/ has a Dockerfile that mounts the project rather than baking it in, so changing an objective is a restart, not a rebuild.
CLI
Working with configuration:
charter validate . # parse and cross-check configuration
charter diff . # compare declared and deployed state
charter apply . # update config, policy and pricing
charter push <agent> <ref> # publish a version to an OCI registry
charter worker . # run agents in this environment
Operating an agent:
charter agent create <agent> # bring an instance into existence
charter run <agent> --flags # start a task
charter agents # every agent and what it's doing
charter describe <agent> # authority, limits, rules, any hold
charter tasks <agent> # task history
charter status <task-id> # result, cost and why it stopped
charter audit <agent> # every governance decision recorded
charter pending <agent> # the open gate, if it's parked on one
charter approve / reject / answer <id>
charter ui # the same, in a browser
charter pause <agent> # stop it taking work
charter resume <agent> --suspension <id>
charter agent delete <agent> # destroy an instance and its history
Read commands take --json for the complete record instead of the curated view.
Architecture
Charter is an opinionated agent layer built on BoundFlow.
charter apply compiles your configuration into workflows and policy on the
BoundFlow control plane. Charter workers run the actual agent loop in your
environment, talking to your MCP servers with credentials that never leave it.
BoundFlow
Control Plane
state • policy • lifecycle
│
RPC
│
▼
Your environment
┌─────────────────────────┐
│ Charter worker │
│ │
│ model ↔ agent loop │
│ │ │
│ MCP tools │
└─────────────────────────┘
Charter introduces no database or service of its own. If the Charter CLI vanished, deployed agents would keep running through their workers and the control plane.
See DESIGN.md for the full configuration reference and the design decisions behind it, and examples/ for complete configurations.
Development
python -m venv .venv
.venv/bin/pip install -e '.[dev,ui,otel]'
.venv/bin/pytest
boundflow comes from PyPI; add --pre --upgrade boundflow to track its main, which
is what CI's second unit job does.
End-to-end tests need a control plane, and skip themselves without one. The compose file CI uses runs the published image:
docker compose -f deploy/local.compose.yml up -d --wait
key=$(docker compose -f deploy/local.compose.yml run --rm server \
-mode=provision -name=dev | awk '/^api_key/{print $NF}')
export BOUNDFLOW_API_KEY=$key
export BOUNDFLOW_SERVER_ADDRESS=http://localhost:50051
export BOUNDFLOW_WORKER_ADDRESS=http://localhost:50052
export CHARTER_STORE_URL=postgres://charter:charter@localhost:5434/charter
pytest tests/e2e
They use a real control plane, a real MCP subprocess and real governance gates. Only the model is faked, so the suite stays deterministic and free.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file boundflow_charter-0.1.0.tar.gz.
File metadata
- Download URL: boundflow_charter-0.1.0.tar.gz
- Upload date:
- Size: 163.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f1243c33333c49648c673b92d4ec469ef6688a2050b41874e73cf32b0345667
|
|
| MD5 |
9e769a81dd808eb523844ad7b99ae188
|
|
| BLAKE2b-256 |
28a0c3f467213680c45760f5a9384d5d9f4cd7a1b762a4c8ecdeac63f8c0b418
|
Provenance
The following attestation bundles were made for boundflow_charter-0.1.0.tar.gz:
Publisher:
publish.yml on boundflow/charter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boundflow_charter-0.1.0.tar.gz -
Subject digest:
9f1243c33333c49648c673b92d4ec469ef6688a2050b41874e73cf32b0345667 - Sigstore transparency entry: 2703335973
- Sigstore integration time:
-
Permalink:
boundflow/charter@643122b6bd587df34dcb19bfbbdcf09fc4f9d6ce -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/boundflow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@643122b6bd587df34dcb19bfbbdcf09fc4f9d6ce -
Trigger Event:
push
-
Statement type:
File details
Details for the file boundflow_charter-0.1.0-py3-none-any.whl.
File metadata
- Download URL: boundflow_charter-0.1.0-py3-none-any.whl
- Upload date:
- Size: 130.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e736ed104baef6597ecaa69c3bb7c8a9a20d7916438a512445fba355f4978428
|
|
| MD5 |
c14be80e8dce35af3a100bfffab11ecd
|
|
| BLAKE2b-256 |
546067a4822de957b922eca8ddedc1b7d5be1493e921f31b5ffb094256af33e8
|
Provenance
The following attestation bundles were made for boundflow_charter-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on boundflow/charter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boundflow_charter-0.1.0-py3-none-any.whl -
Subject digest:
e736ed104baef6597ecaa69c3bb7c8a9a20d7916438a512445fba355f4978428 - Sigstore transparency entry: 2703335994
- Sigstore integration time:
-
Permalink:
boundflow/charter@643122b6bd587df34dcb19bfbbdcf09fc4f9d6ce -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/boundflow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@643122b6bd587df34dcb19bfbbdcf09fc4f9d6ce -
Trigger Event:
push
-
Statement type: