What is a battery

A battery is a reusable OpenAPPA policy configuration for a set of tools, such as Slack MCP or built-in Claude Code tools. It can also include annotators, sanitizers, and authorities, with implementations that can be any executable program.

Most batteries are for MCP servers.

Battery structure#

Keep each battery and its annotator scripts together:

batteries/
|-- claude-code/
|   `-- appa.toml
`-- slack/
    |-- appa.toml
    `-- audience-source.py

To include batteries in your config, list them in appa.toml:

include = [
  "./batteries/claude-code/appa.toml",
  "./batteries/slack/appa.toml",
]

[policy]
version = 2

OpenAPPA combines these files into one config.

ItemRule
includeOnly the root file can use it.
PathsPaths are local and relative to the root file.
VersionEvery file uses the same version.
AnnotatorsEach annotator name must be unique.
Script pathA script path is relative to the config file that names it.
Audience sourcesA battery binds the membership service it ships and declares its selector templates. A root entry for the same provider is a duplicate.
Confined resultsA battery can list its own tools in confined_results, so an output sanitizer it ships has a result to run on. The names join the root's list. Other [policy.deployment] settings stay in the root.
BindingsA battery binds the helper scripts it ships, and a sanitizer to a stock builtin such as redact-secrets. A url, a model, or a stock authority is the root's to bind.

OpenAPPA checks the combined config before using it. If a reload fails, the current config keeps running.

Policy order when batteries are used#

OpenAPPA checks root rules from top to bottom. It then checks each battery in order, also from top to bottom. The first match wins.

Root appa.tomlRules run from top to bottomFirst file in includeRules run from top to bottomNext file in includeRules run from top to bottom

In this example, the Linear battery requires review for every comment. The root config overrides this rule for one issue, so comments on it do not need review:

# root config: the agent's own status issue takes comments without a question
[[policy.tool]]
name = "mcp/linear/save_comment(issueId:ENG-42)"
requires = { trust = "trusted", audience = { contains = ["@linear:issue/$issueId/readers"] } }
delta = { audience = ["@linear:issue/$issueId/readers"] }

# included battery: every other comment needs fresh review
[[policy.tool]]
name = "mcp/linear/save_comment(issueId:*)"
requires = { trust = "trusted", audience = { contains = ["@linear:issue/$issueId/readers"] }, attention = ["linear-review"] }
delta = { audience = ["@linear:issue/$issueId/readers"] }

In another example, the battery keeps the trust of every conversation inside your workspace, including text an installed integration posts. A root rule can mark one channel as untrusted, such as a channel where an integration relays customer emails:

# root config: the support inbox relays words written outside the company
[[policy.tool]]
name = "mcp/claude_ai_Slack/slack_read_channel(channel_id:C0SUPPORT*)"
delta = { trust = "suspicious", audience = ["@slack:channel/$channel_id"] }

# included battery: an annotator asks Slack whether the channel is shared
# with another organization
[[policy.tool]]
name = "mcp/claude_ai_Slack/slack_read_channel"
annotator = "slack.conversation-trust"

Use annotators in batteries#

A battery can ship an annotator script beside its config. An annotator sets a tool's contract for each call. A root config can use an annotator supplied by an included battery. See Annotators for what annotators receive and return.

This example uses approve-hidden-file-read. It calls a local Python script that checks whether the file name starts with a dot. If it does, the script returns an attention requirement before the file is read:

[[policy.annotator]]
name = "approve-hidden-file-read"
audiences = []
marks = ["hitl"]

[[policy.tool]]
name = "host/claude-code/Read"
annotator = "approve-hidden-file-read"

[externals.annotators.approve-hidden-file-read]
command = ["python3", "approve-hidden-file-read.py"]

Use sanitizers in batteries#

A battery can ship a sanitizer script beside its config. A sanitizer rewrites data before a tool receives it or before a result returns to the agent. A root config can use a sanitizer supplied by an included battery. See Sanitizers for what sanitizers receive and return.

This example uses remove-email-addresses. It calls a local Python script that removes email addresses before a message is sent:

[[policy.tool]]
name = "mcp/messaging/SendMessage"
tags = ["messages"]
requires = { audience = { contains = ["public"] } }
delta = {}

[[policy.sanitizer]]
name = "remove-email-addresses"
on = ["tool_input"]
tags = ["messages"]

[policy.sanitizer.permits]
audience = { from = ["internal"], to = ["public"] }

[externals.sanitizers.remove-email-addresses]
command = ["python3", "remove-email-addresses.py"]

Use authorities in batteries#

A battery can ship an authority script beside its config. An authority approves or denies one blocked tool call within declared limits. A root config can use an authority supplied by an included battery. See Authorities for what authorities receive and return.

This example uses approve-small-payment. It calls a local Python script that approves payments of USD 100 or less. Larger payments remain blocked:

[[policy.authority]]
name = "approve-small-payment"

[policy.authority.permits]
attention = ["payment-approval"]

[[policy.tool]]
name = "mcp/payments/SendPayment"
requires = { attention = ["payment-approval"] }
delta = {}

[externals.authorities.approve-small-payment]
command = ["python3", "approve-small-payment.py"]

Audience sources#

A battery that covers a provider with its own directory ships a membership service beside its config and binds it. The binding declares the selector templates the service understands under ; the battery's own contracts may then name those collections, including per-resource ones such as one channel's members:

# batteries/slack/appa.toml
[[policy.tool]]
name = "mcp/claude_ai_Slack/slack_send_message"
delta = {}
requires = { trust = "trusted", audience = { contains = ["@slack:channel/$channel_id"] } }

[externals.audience.slack]
command = ["python3", "audience-source.py"]
token_env = "APPA_PROVIDER_SLACK_TOKEN"
selectors = [
  { template = "viewer", feeds = "self" },
  { template = "full-members", feeds = "internal" },
  { template = "user-group/<handle>" },
  { template = "channel/<id>" },
]

The root config decides what the built-in audiences mean and supplies the credential. It maps and under , which only the root can carry, and exports the token variable the battery names. A root entry for the same provider is a duplicate binding and fails to load.

# root config
include = ["./batteries/slack/appa.toml"]

[policy]
version = 2

[policy.audience]
self = ["slack:viewer"]
internal = ["slack:full-members"]

Every consult OpenAPPA sends the service carries the declared templates as declaration.templates. The shipped services compare that list with the templates they serve and refuse a mismatch before reading their token, and the start-up probe of viewer and full-members triggers that check before any agent call. See Configure audience membership for the declaration and the request protocol.

Customise a battery#

Define tool rules and Annotator customizations in the root config. Root tool rules run before battery rules, even when they appear below . A root Annotator declaration replaces the battery declaration with the same name.

include = [
  "./batteries/claude-code/appa.toml",
  "./batteries/slack/appa.toml",
]

[policy]
version = 2

[[policy.tool]]
name = "host/claude-code/Bash(command:kubectl)"
requires = { attention = ["blocked"] }
delta = { trust = "suspicious", audience = ["internal"] }

[[policy.tool]]
name = "host/claude-code/Bash(command:kubectl *)"
requires = { attention = ["blocked"] }
delta = { trust = "suspicious", audience = ["internal"] }

[[policy.annotator]]
name = "local.read-sensitivity"
audiences = []
marks = ["hitl"]

# This root rule runs before the battery's Read rules.
[[policy.tool]]
name = "host/claude-code/Read"
annotator = "local.read-sensitivity"

[externals.annotators."local.read-sensitivity"]
command = ["python3", "./local/read-sensitivity.py"]

is the reserved mark no authority can permit, so both kubectl contracts have no remedy, even under the plugin's catch-all hitl authority. Every other Bash call reaches the battery's model annotator. The root also replaces the battery's host/claude-code/Read rules with a local script.

The battery files stay unchanged.