The AI integration lets a playbook hand a piece of text to a language model and use what comes back. One Binions daemon — the AI injector — owns all model access through a registry of named provider instances: Anthropic’s Claude, anything that speaks the OpenAI chat API (cloud services and local runtimes alike), and Google’s Gemini. Any number of instances run side by side, and playbooks route to them by name — one step to a local model, the next to a frontier one. This page is the integration-level view: which services Binions can talk to, how you declare them, how steps choose between them, and how you keep the spend governed. For the full service reference, see AI injector service.
Binions does not bake a model into every daemon. Instead, one broker — the AI injector — owns all model access, and each step names the provider instance that should answer. That keeps the rest of the platform model-agnostic: a playbook step says “classify this” or “extract these fields,” and only the AI injector knows whether the answer came from a hosted API or a model running on your own hardware. Swap the instance, keep the playbook.
The integration uses the ai. playbook prefix and the AI event domain. There are exactly five operations (they also appear in the verb vocabulary):
| Verb | What it does | Result fact |
|---|---|---|
ai.classify | Picks exactly one label from a list you supply — routing, triage, tagging. The answer is guaranteed to be one of your categories. | Fact.AI.Classified |
ai.extract | Pulls named, typed fields out of free text into a JSON object — invoices, forms, emails. | Fact.AI.Extracted |
ai.inject | A general prompt-and-completion call — summaries, drafts, rewrites. | Fact.AI.Generated |
ai.batch | Runs classify or extract across a list of up to 200 items in one step, under a running cost cap. | Fact.AI.BatchCompleted |
ai.usage_report | Reads the usage ledger back into the playbook — spend by provider, by model, or for the current run. | Fact.AI.UsageReported |
Every result fact echoes the instance that answered in provider and the model that was used, so parallel takes from different instances stay distinguishable downstream. And every successful call is metered — calls, input tokens, output tokens and estimated cost — into a usage ledger per provider and model (see Cost governance below). There is no separate “generate” or “embedding” verb — if you need free-form generation, that is what ai.inject is for.
Provider configuration lives in the AI injector’s own config file. Each [providers.instances.<name>] block declares one named instance — a type, an endpoint, an optional key file and a default model — and any number of instances run side by side. The name is yours (primary, local, deepseek…): it is what playbook steps route on, and what result facts report back.
Three instance types cover the whole landscape:
| Type | Talks to | Notes |
|---|---|---|
anthropic | Anthropic’s Messages API (the Claude family) | Claude-family models come with built-in pricing defaults. |
openai_compatible | Anything that speaks the OpenAI chat API: OpenAI itself, OpenRouter, Z.ai, DeepSeek — and local runtimes such as Ollama, vLLM and LM-Studio | One client covers them all, cloud or local; you just change base_url. |
google | Google’s Gemini models | A preset of the same OpenAI-compatible client, pointed at Gemini. |
Each instance block takes the same handful of keys:
| Key | What it does |
|---|---|
type | anthropic, openai_compatible or google. |
base_url | The endpoint to call. Point an openai_compatible instance at any cloud or local URL; the anthropic and google types come with sensible presets. |
key_file | Path to a file holding the API key. Optional — local endpoints run keyless, so simply leave it out. |
default_model | The model used when a step does not name one. |
supports_json_mode | Whether the endpoint offers a native JSON output mode. |
A typical mixed setup pairs one cloud instance with one local one:
[providers.instances.primary]
type = "anthropic"
key_file = "/opt/binions/aiinjector-service/secrets/anthropic.key"
default_model = "claude-sonnet-4-5"
[providers.instances.local]
type = "openai_compatible" # OpenAI, OpenRouter, DeepSeek, Ollama, vLLM, LM-Studio…
base_url = "http://127.0.0.1:11434/v1" # keyless local endpoint
default_model = "llama3.1:8b"
Add as many blocks as you have services — a cheap cloud model for triage, a frontier model for extraction, an on-premises model for anything sensitive. Playbooks pick between them per step, so mixing is the normal case, not a special one.
Keep sensitive text on-premises. An
openai_compatibleinstance pointed at Ollama, vLLM or LM-Studio on your own hardware runs keyless and never sends text off the host. Route routine or confidential steps to the local instance, and reserve cloud instances for the steps that genuinely need frontier quality.
Keys are always read from a file — they are never written inline in config. Create the key file with locked-down permissions, then restart the daemon so it picks the key up:
# write the key to its file (never paste it into the .toml)
sudo install -m 600 -o aisvc -g aisvc /dev/stdin \
/opt/binions/aiinjector-service/secrets/anthropic.key <<< "sk-ant-..."
# restart so the instance registers
sudo systemctl restart binions-aiinjector
# confirm the daemon came back ready
binions-cliconsole status
The platform stores all secrets this way — as 0600 files owned by the service user, never in the config body. See Secrets & credentials for the full convention.
Keys stay on your host. An API key is read from a file you own and is used only to authenticate outbound calls to that one provider. Make each file readable by the service user alone, and rotate a key by replacing its file and restarting the daemon.
Upgrading from an older single-provider setup? A config that sets only the legacy anthropic_key_file still works: the daemon builds an instance named anthropic from it automatically, so existing playbooks that say provider: anthropic keep running unchanged.
Routing is per step. provider: names the instance that should answer; model: optionally overrides that instance’s default_model. A step may omit model: entirely and take the instance default — and a call that ends up with no model at all (no model:, no default_model) fails loudly as a bad_request rather than guessing.
steps:
# Routes to the local instance; its default_model answers
- id: triage
run: ai.classify
with:
provider: local
text: ${trigger.envelope.body_text}
categories: [sales, support, billing, spam]
# ${steps.triage.category} is now one of the four labels
# Same verb family, another instance, explicit model override
- id: summarise
run: ai.inject
with:
provider: primary
model: claude-haiku-4-5
prompt: "Summarise this ticket in one sentence."
The third routing field is providers: — an ordered fallback chain of one to eight instance names, available on ai.inject, ai.classify, ai.extract and ai.batch (and mutually exclusive with provider:). A name that is not in the registry fails immediately — that is a configuration mistake, not something to paper over. A runtime provider error — a timeout, a rate limit, an authentication failure — falls through to the next name in the chain, so a flaky cloud endpoint degrades to your local model instead of stopping the workflow.
The two examples below are the bread-and-butter AI integrations. Both use only real verbs and argument names; the instance names (local, deepseek, primary) are whatever you declared in the registry.
Two instances classify the same email in parallel — one local, one cloud — then ai.usage_report with group_by: correlation prices exactly this run (when no correlation id is given, the report covers the current playbook run), and everything lands in an audit table:
name: ai-two-providers-usage
description: Classify with a local LLM and a cloud model, then report the cost.
trigger:
event: Fact.Mail.Received
filter:
via.eq: biuro
steps:
- parallel:
- id: local-take
run: ai.classify
with:
provider: local
text: "${trigger.body_text}"
categories: [invoice, complaint, spam, other]
- id: cloud-take
run: ai.classify
with:
provider: deepseek
text: "${trigger.body_text}"
categories: [invoice, complaint, spam, other]
- id: cost
run: ai.usage_report
with:
group_by: correlation
- id: persist
run: database.write
with:
table: classification_audit
row:
local_category: "${steps.local-take.category}"
cloud_category: "${steps.cloud-take.category}"
run_cost_usd: "${steps.cost.total.cost_usd}"
run_calls: "${steps.cost.total.calls}"
Turn the free text of an invoice email into a tidy JSON object. ai.extract takes the text and a list of fields, each with a name, a type (text, int, decimal, bool or date — enforced by coercion) and an optional hint. Here the step also carries a fallback chain and a pre-flight cost cap:
trigger:
event: Fact.Mail.Received
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
providers: [primary, local]
max_cost_usd: 0.05
on_mismatch: fail
fields:
- name: supplier_name
type: text
hint: "company issuing the invoice"
- name: invoice_number
type: text
hint: "invoice or document reference"
- name: amount_gross
type: decimal
hint: "total amount including tax"
- name: currency
type: text
hint: "ISO 4217 code, e.g. GBP/EUR/USD"
# ${steps.extract.result} is the JSON object of extracted fields
Extraction is enforced, not best-effort: a reply that is not valid JSON fails as bad_output, and a value that cannot be coerced to its declared type fails as type_mismatch (or lands as null or the raw string if you choose on_mismatch: null / raw). Note the interpolation path: ai.extract is the one verb whose payload nests under result — read fields as ${steps.extract.result.amount_gross}.
Every successful call is metered into a usage ledger: calls, input tokens, output tokens and estimated cost, per provider and model, on three axes at once — all-time totals, daily buckets, and per-correlation (per playbook run, kept for 30 days). Failures that still consumed tokens — a bad_output or a type_mismatch — are metered too, because the tokens were spent.
Prices come from a [providers.pricing] table in the same config file, expressed per million tokens:
[providers.pricing."deepseek-chat"]
input_per_1m = 0.27 # USD per 1M input tokens
output_per_1m = 1.10 # Claude-family models have built-in defaults
A key of the form "<instance>/<model>" wins over a bare "<model>" key, so the same model name can be priced differently per endpoint. Claude-family models have built-in default prices; a model with no price at all still meters its calls and tokens but records 0.0 cost.
Two verbs and one argument turn the ledger into governance:
ai.usage_report reads the ledger back: group_by is provider, model (the default) or correlation, and optional since/until dates (YYYY-MM-DD, UTC daily buckets, available with the provider/model groupings) window the report — leave them out for all-time totals. The result fact Fact.AI.UsageReported carries rows[] plus a total. With group_by: correlation and no id, the report covers the current run — “what has this playbook cost so far?” as a step.max_cost_usd is a pre-flight cap on any AI step: the daemon estimates the call’s worst-case price from the input size and the maximum output it may produce, and refuses the call if the estimate exceeds the cap. The check is fail-closed: an instance/model pair with no price fails with reason cost_unknown rather than letting unmeterable spend through.The cost figure is meant for reasoning about and budgeting spend — logging it per run, summing it per day, or gating an expensive step — not as a billing-grade invoice. Provider prices change over time; treat cost_usd as a good estimate, and reconcile against your provider’s own dashboard for the authoritative number.
Real calls cost real money. The moment a step routes to a cloud instance with a valid key, that call bills against your account. Price your models in
[providers.pricing]so the ledger means something, putmax_cost_usdon the expensive steps, keep bulk work onai.batchwith a cap, and let a scheduledai.usage_reporttell you where the money went.
Every failure path in the AI injector emits Fact.AI.OperationFailed with op, reason, message and the source_event_id of the action that caused it (plus provider and model when known). The reasons are typed — timeout, network, auth_failed, rate_limit, bad_request, provider_error, not_configured, an unknown provider name, bad_output (a classify outside its categories or a non-JSON extract) and type_mismatch — so a failure is never a silent shrug. It is also a trigger event, which makes the reactive alert playbook a one-pager:
name: ai-failure-to-ops-mail
description: Every AI injector failure is a reactable fact.
trigger:
event: Fact.AI.OperationFailed
steps:
- id: page
run: mail.send
with:
from_alias: ops-out
to: [ "ops@example.com" ]
subject: "AI ${trigger.op} failed: ${trigger.reason}"
body_text: |
An aiinjector operation failed.
op: ${trigger.op}
reason: ${trigger.reason}
message: ${trigger.message}
Source action event id: ${trigger.source_event_id}
Between the two mechanisms you get layered resilience: a providers: chain absorbs transient provider trouble in-line, and Fact.AI.OperationFailed catches whatever remains — for an alert, a retry playbook, or a hand-off to a human.
ai.batchFor “classify these 150 tickets” jobs, ai.batch runs one operation (op: classify or op: extract) across a list of up to 200 items, with the same categories/fields, routing (provider/providers, model) and on_mismatch options as the single-shot verbs. Items are processed sequentially, and a failing item lands in results[] without killing the batch. max_cost_usd here is a running cap: once the accumulated cost reaches it, the remaining items are skipped and the summary says stopped_reason: cost_limit_reached. The step completes on the single summary fact Fact.AI.BatchCompleted, which carries results[], ok, failed, input_tokens, output_tokens and cost_usd:
name: batch-classify-tickets
description: |
One ai.batch call classifies every ticket text POSTed to /in/tickets;
the batch summary lands in SQL for the dashboard.
trigger:
event: Fact.Http.Received
filter:
route.eq: tickets
steps:
- id: classify_all
run: ai.batch
with:
op: classify
items: ${trigger.body.texts}
categories: [billing, outage, feature, spam]
max_cost_usd: 0.25
- id: persist
run: database.write
with:
table: ticket_batches
row:
ok: ${steps.classify_all.ok}
failed: ${steps.classify_all.failed}
cost_usd: ${steps.classify_all.cost_usd}