aiinjector-service is the platform's AI broker — the daemon that classifies, extracts, and generates text through the AI providers you configure. It gives your playbooks five plain-language abilities: sort a piece of text into categories, pull named, typed fields out of free-form content, generate new text from a prompt, run either of the first two over a whole list of items in one batched call, and report what your AI usage has cost. You describe what you want in short, readable YAML; the daemon talks to the AI model on your behalf and returns the result as an event the rest of your workflow can use. Swapping the model or provider is a one-line change — your playbooks stay the same.
Good to know. aiinjector-service ships with an offline stub provider as its default, so it works out of the box with no account, no API key, and no cost — ideal while you build and test playbooks. Point it at one or more real providers when you are ready, and the same playbooks start producing real AI output.
Binions is event-driven: daemons talk to each other over a fast internal event bus, and a playbook addresses a daemon by naming a lowercase verb. aiinjector-service is the daemon that owns anything involving a language model. When a playbook step asks it to classify, extract, generate, batch, or report usage, the daemon builds the request, sends it to whichever provider instance you named, and publishes the answer back onto the bus — so a later step in the same workflow can read it and act on it.
It does exactly five things, and they compose into a great deal:
The first three primitives cover the lion's share of practical AI work — routing, tagging, data capture, drafting, and summarising are all just one of these verbs with a different prompt or category list — and the last two wrap them in scale and accountability. There is no separate verb to learn for every task.
| What it is | The AI broker — classify, extract, and generate text, in single calls or batches, through the providers you configure |
| Playbook prefix | ai. |
| Operations | 5 — classify, extract, inject, batch, usage_report |
| Default provider | stub — offline, deterministic, free; great for development and tests |
| Real providers | Named instances of type anthropic (Claude), openai_compatible (OpenAI, OpenRouter, Z.ai, DeepSeek, Ollama, vLLM, LM-Studio…), or google (Gemini) — any number side by side (see AI providers) |
| Routing | provider: <name> per step, or providers: […] as an ordered fallback chain of up to 8; model: is optional — every instance has a default_model |
| Cost control | max_cost_usd pre-flight cap per call (a running cap on batches); [providers.pricing] price table; per provider × model usage ledger queried with ai.usage_report |
| Failure contract | Every failure path emits Fact.AI.OperationFailed — a trigger event for fallback and alert playbooks |
| Usage reported | Every result carries input_tokens, output_tokens, and a best-effort cost_usd, and echoes the model and provider instance that answered |
| Service | binions-aiinjector.service with a dedicated redis-binions-aiinjector.service |
| Health endpoint | 127.0.0.1:9104 — /health/live, /health/ready, /metrics |
| Package | binions-aiinjector — one of the 13 binions in a set |
In a playbook you address the daemon with the lowercase verb form run: ai.<operation>. There are five operations. The text verbs take the text or prompt to work on and route through a provider instance: provider: names a single instance, providers: lists an ordered fallback chain, and when both are omitted the configured default is used. model: is optional everywhere — leave it out and the chosen instance's default_model answers (a step with no model and an instance with no default fails loudly as bad_request).
| Operation | What it does | Arguments |
|---|---|---|
ai.classify | Sort a piece of text into one of a fixed list of categories you supply. The answer is guaranteed to be one of them. | text, categories (list); optional model, provider or providers, max_cost_usd |
ai.extract | Pull named, typed fields out of free-form text and return them as a keyed result object. | text, fields (list of {name, hint, type}); optional on_mismatch, model, provider or providers, max_cost_usd |
ai.inject | Generate new text from a prompt — a draft, a summary, a rewrite. | prompt; optional system, temperature, max_tokens (default 1024), model, provider or providers, max_cost_usd |
ai.batch | Run classify or extract over a whole list of items in one call, answered by one summary fact. | op (classify or extract), items (list, 1–200), categories or fields to match; optional on_mismatch, model, provider or providers, max_cost_usd |
ai.usage_report | Report metered calls, tokens, and cost from the built-in usage ledger. | group_by (provider, model, or correlation; default model); optional since, until (YYYY-MM-DD) |
These five verbs are the whole vocabulary. aiinjector-service supports exactly
ai.classify,ai.extract,ai.inject,ai.batch, andai.usage_report. There is no separate “summarise,” “translate,” or “embed” verb — tasks like those are expressed as anai.injectwith the right prompt. If an old example refers to anotherai.*verb, treat it as out of date and use one of these five.
Whichever operation you call, the result event reports how much work the model did and who did it. Every result carries the same usage fields, so you can log them, sum them, or act on them:
input_tokens — how much text was sent to the model (its tokenised length).output_tokens — how much text the model produced.cost_usd — a best-effort cost estimate for the call. With the stub provider this is always 0; with a real provider it is derived from the price table for the model used (see Understanding cost below).model — the model that actually answered. Classified and extracted results echo it just like generated text does, so you always know which model produced a given answer even when a step relied on an instance's default_model.Results also echo the provider instance name that handled the call, so two instances pointing at the same vendor stay distinguishable in your logs and in the ledger. And every successful call is metered into a persistent usage ledger you can query with ai.usage_report — see The usage ledger below.
A common first job: when an email arrives, decide what it is about so a later step can route it. The mailbox daemon emits an event for each new message; this playbook hands the body to ai.classify with a fixed list of categories.
name: classify-inbound-support-email
trigger:
event: Fact.Mail.Received
filter: { via.eq: support-inbox }
steps:
- run: ai.classify
id: intent
with:
text: "${trigger.body_text}"
categories: [support, sales, billing, spam]
model: claude-haiku-4-5
# A later step can branch on ${steps.intent.category}
# — e.g. file billing mail in one folder, sales in another.
The result carries the chosen category alongside the usual model, input_tokens, output_tokens, and cost_usd. Because the categories are a fixed list you supply, the answer is guaranteed to be one of your own labels — easy for the next step to switch on. If the model ever answers outside the list, the call fails loudly as bad_output rather than handing you a surprise label.
Turn the free text of an invoice into structured data your other daemons can store or send onward. You list the fields you want; each gives a name, a short hint describing it, and a type. Five canonical types are enforced, not merely suggested: text, int, decimal, bool, and date. The daemon coerces the model's answer into them — an amount like “1 234,56” in European or US notation (non-breaking spaces normalised) becomes a real number, true/false/1/0 become booleans, and dates are accepted as RFC 3339 or YYYY-MM-DD — so what lands in your database is a value, not a string that looks like one.
name: extract-invoice-fields
trigger:
event: Fact.Mail.Received
filter: { via.eq: invoices }
steps:
- run: ai.extract
id: invoice
with:
text: "${trigger.body_text}"
model: claude-sonnet-4-5
on_mismatch: fail
fields:
- { name: supplier, type: text, hint: "the company that issued the invoice" }
- { name: invoice_number, type: text, hint: "the invoice reference or number" }
- { name: amount_gross, type: decimal, hint: "the total payable including tax" }
- { name: due_date, type: date, hint: "the payment due date" }
# ${steps.invoice.result.supplier}, ${steps.invoice.result.amount_gross}, ...
# can now be written to a database with database.write, posted to a webhook, and so on.
The extracted values come back as a keyed object under the step's id — always nested under result, so the next step reads ${steps.invoice.result.supplier} and friends — for instance, persisting them with database-service. on_mismatch decides what happens when a value refuses to coerce: fail (the default) stops the step with a loud type_mismatch failure, null records a null instead, and raw keeps the model's raw string. A type outside the canonical five (say, string or number) is not an error — it is simply passed to the model as a hint, with no coercion applied.
Generate fresh text from a prompt. Here we draft a first-pass reply to a support email. An optional system message sets the tone, temperature controls how creative the output is, and max_tokens caps its length (it defaults to 1024 if omitted).
name: draft-support-reply
trigger:
event: Fact.AI.Classified
filter: { category.eq: support }
steps:
- run: ai.inject
id: draft
with:
system: "You are a concise, friendly support agent. Reply in plain English."
prompt: |
Write a short, helpful reply to this customer message:
${trigger.text}
model: claude-sonnet-4-5
temperature: 0.4
max_tokens: 400
# ${steps.draft.text} is the generated reply — send it for review,
# save it as a draft, or hand it to the mailbox daemon to send.
The generated text comes back under the step's id, again with the usage fields attached. A natural next move is to chain these examples together — classify a message, and only draft a reply when the category is support, exactly as the trigger above does.
The model names in these examples (claude-haiku-4-5, claude-sonnet-4-5) are illustrative. Because the model is just a field — and an optional one — you can pick a small, fast model for high-volume classification and a larger one for nuanced drafting, or leave model out entirely and let each instance's default_model decide — tuning cost against quality per step without rewriting the workflow.
When you have a list — a day's tickets, a folder of records, a page of comments — ai.batch handles it in one step instead of one call per item. Give it the operation to run (op: classify or op: extract), the items (from 1 to 200 of them), and the same categories or fields you would pass to the single-item verb. The daemon works through the items sequentially and answers with a single Fact.AI.BatchCompleted summarising the whole run.
results[] array, and the ok and failed counters tally them. A failed item is recorded and the batch moves on.max_cost_usd is a running cap: spend is tallied as items complete, and once the cap is reached the remaining items are skipped and the summary carries stopped_reason: cost_limit_reached.input_tokens, output_tokens, cost_usd.name: batch-classify-tickets
description: |
Business — 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:
count: ${steps.classify_all.count}
ok: ${steps.classify_all.ok}
failed: ${steps.classify_all.failed}
cost_usd: ${steps.classify_all.cost_usd}
The playbook's saga step completes on the single summary fact, so a following step reads the batch's counters directly — exactly as the persist step above does.
AI calls fail for mundane reasons: a provider times out, a key expires, a rate limit bites, a model returns something that is not what you asked for. aiinjector-service turns every failure path into an event: Fact.AI.OperationFailed, carrying the operation (op), a machine-readable reason, a human-readable message, the source_event_id of the action that failed, and — when known — the provider and model involved. Nothing fails silently, and because the failure is an ordinary fact on the bus, a playbook can trigger on it.
reason | When you see it |
|---|---|
timeout, network | The provider could not be reached, or did not answer in time. |
auth_failed | The provider rejected the API key. |
rate_limit | The provider throttled the call. |
bad_request | The request was malformed — for example, no model on the step and no default_model on the instance. |
provider_error | The provider returned an error of its own. |
not_configured | The step names a provider that is not configured. |
bad_output | The model's answer broke the contract — a classification outside your categories, or an extraction that was not usable JSON. |
type_mismatch | A typed extract value could not be coerced, with on_mismatch: fail. |
cost_unknown | max_cost_usd was set but the instance/model pair has no price — the daemon fails closed rather than guessing. |
Two guarantees follow from this design. First, a Classified result's category is always one of the categories you supplied. Second, an Extracted result's result is always the keyed object with the field names you asked for. There are no silent raw-text fallbacks — an answer that does not meet the contract becomes a bad_output failure instead of quietly polluting your data.
Because Fact.AI.OperationFailed is a trigger event, alerting on every AI failure is a four-line playbook — and the same pattern drives retry or fallback logic:
name: ai-failure-to-ops-mail
description: Business — EVERY aiinjector 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}
For the common “if the first provider is down, try the second” case you rarely need a reactive playbook at all — an in-line providers: fallback chain handles it in the step itself, as described next.
aiinjector-service does not hard-wire one AI vendor. You declare provider instances in its configuration — each one a named entry such as [providers.instances.primary] — and run as many side by side as you like: a cloud model for quality, a local model for volume, two accounts with separate budgets. A playbook step routes with provider: <name> — the instance name, not the vendor — and the result fact echoes that instance name, so different instances stay distinguishable even when they point at the same vendor.
Every instance declares a type plus a handful of fields: base_url (where the endpoint lives), an optional key_file (local endpoints run keyless), the default_model used when a step omits model:, and supports_json_mode, which marks endpoints that honour JSON-mode output. Three instance types cover the market:
| Instance type | What it covers |
|---|---|
openai_compatible | One client, most of the market: OpenAI, OpenRouter, Z.ai, DeepSeek, Ollama, vLLM, LM-Studio — anything that speaks the OpenAI-compatible chat API. Point base_url at the endpoint; add a key_file only if the endpoint needs one — a local Ollama or vLLM runs keyless. |
anthropic | Anthropic's Claude models, spoken natively. The legacy anthropic_key_file setting still works and auto-builds an instance named anthropic when the key file exists and is non-empty — existing configurations keep running unchanged. |
google | Google's Gemini models, as a preset of the same OpenAI-compatible client — the base_url comes preconfigured for Gemini's OpenAI-compatible endpoint. |
The built-in stub provider remains the out-of-the-box default: offline, deterministic, always cost_usd: 0 — perfect for building and testing playbooks, and for automated tests where you want a stable answer.
The pattern is deliberately uniform: one verb, an instance name, an optional model. Your playbook says what it wants done — classify, extract, inject, batch — while provider and model say who should do it. Omit provider and the configured default (the stub out of the box) is used; omit model and the instance's default_model answers.
For resilience, a step can hand over an ordered list instead of a single name: providers: [primary, local] — up to eight instances, mutually exclusive with provider:. Instance names are checked up front, so a chain naming an unknown instance fails fast before any call is made; runtime errors — a timeout, a rate limit, a provider error — fall through to the next instance in order. Combined with a cost cap, this is the “try the good model, fall back to the local one, never spend more than five cents” pattern:
- id: extract
run: ai.extract
with:
text: ${steps.parse.text}
providers: [primary, local]
max_cost_usd: 0.05
on_mismatch: fail
fields:
- { name: no, type: text }
- { name: total, type: decimal }
- { name: sender, type: text }
(Here the text came from an earlier data.parse step that read a PDF's text layer — see datatransporter-service for that half of the pipeline.)
The cost_usd on every result is a best-effort estimate, not a billed figure. It is computed from a price table: [providers.pricing."<model>"] entries give a model's input_per_1m and output_per_1m USD rates, an instance-qualified key like "<instance>/<model>" overrides a bare model key, and Claude-family models are priced by built-in defaults. A model with no price still meters its calls and tokens — its cost is simply recorded as 0.0. The stub provider always reports 0 because it makes no paid call.
max_cost_usd turns the estimate into a guard rail. Before contacting the provider, the daemon estimates the call's worst case — input tokens as characters ÷ 4 plus a 64-token overhead, output as the call's full max_tokens — and refuses the call if the estimate exceeds the cap. The check fails closed: if the instance/model pair is unpriced, a capped call is refused with cost_unknown rather than let through on a guess. On ai.batch the same cap is applied as a running total across items.
Your keys stay on your host. A provider's API key is read from a file on your own machine and never appears in a playbook, an event, or a log line. The full set-up — where key files live and how to declare instances — is covered in AI providers and Secrets & credentials.
Every successful call — and every batch item — is metered: calls, input_tokens, output_tokens, and cost_usd, counted per provider × model, on three axes at once:
Failures that consumed tokens still meter: a bad_output or type_mismatch call spent real tokens, and the ledger records them. You query the ledger with ai.usage_report: group_by chooses provider, model (the default), or correlation, and optional since / until dates (YYYY-MM-DD) read the daily buckets in UTC — they apply to provider and model groupings, and leaving them out reports all-time totals. The answer is a Fact.AI.UsageReported with rows[] (one per group) and a total summing them. With group_by: correlation and no explicit id, the report scopes to the current playbook run — the mid-run “what has this run cost so far”:
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}"
Two instances classify the same message in parallel — a keyless local endpoint and a cloud model — then ai.usage_report prices this very run before the audit row is written. The same verb, on a schedule with group_by: model and a date range, becomes your monthly AI cost report.
Configuration lives in the daemon's application.toml. The [redis] block points at the dedicated Redis instance that holds the daemon's working state, including the usage ledger; [healthcheck] exposes the local health and metrics server; and [providers] declares your provider instances and the price table. An optional [otel] block enables distributed tracing.
# /opt/binions/aiinjector-service/config/application.toml
# This daemon's dedicated Redis instance (state, outbox, idempotency, usage ledger)
[redis]
host = "127.0.0.1"
port = 6394
password_file = "/etc/binions/secrets/aiinjector-redis.pass"
# Local health and metrics HTTP endpoint
[healthcheck]
listen_addr = "127.0.0.1:9104"
# AI provider registry
[providers]
default = "stub"
# Legacy single-provider key: still honoured. When this file exists and is
# non-empty, it auto-builds an instance named "anthropic".
anthropic_key_file = "/etc/binions/secrets/anthropic.key"
# Named instances — declare as many as you need, side by side
[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"
# Price table for cost metering and max_cost_usd checks
[providers.pricing."deepseek-chat"]
input_per_1m = 0.27 # USD per 1M input tokens; key "<instance>/<model>" beats bare "<model>"
output_per_1m = 1.10 # Claude-family models have built-in defaults
# Optional: export traces to a collector for end-to-end visibility
# [otel]
# endpoint = "http://127.0.0.1:4317"
default = "stub" keeps the daemon offline and free until you deliberately point it at a real model.[providers.instances.<name>] entry becomes a name your playbooks route to with provider: or in a providers: chain. key_file is optional — local endpoints run keyless — and supports_json_mode marks endpoints that honour JSON-mode output.anthropic_key_file auto-builds an instance named anthropic; leave the file absent or empty and it stays off. Upgrading to explicit instances is optional, not required.[providers.pricing."<model>"] rows drive cost_usd on results, the usage ledger, and max_cost_usd checks; an "<instance>/<model>" key wins over a bare model key, and Claude-family models are priced out of the box.Each operation publishes its result as an event on the internal bus, so other playbooks and your monitoring can react to what the AI returned — and to what it failed to return. Each result carries the work product (the chosen category, the extracted result object, the generated text, the batch summary, or the usage rows) together with the model and the input_tokens, output_tokens, and cost_usd usage fields.
| Event | Meaning |
|---|---|
Fact.AI.Classified | An ai.classify call completed. Carries the chosen category — guaranteed one of yours — plus model and the usage fields. |
Fact.AI.Extracted | An ai.extract call completed. Carries result, the keyed object of your typed fields, plus model and the usage fields. |
Fact.AI.Generated | An ai.inject call completed. Carries the generated text plus model and the usage fields. |
Fact.AI.BatchCompleted | An ai.batch finished (or stopped at its cost cap). One fact per batch: results[], ok, failed, batch-total usage, and stopped_reason when it stopped early. |
Fact.AI.UsageReported | An ai.usage_report answered. Carries rows[] and total from the usage ledger. |
Fact.AI.OperationFailed | Any ai.* operation failed. Carries op, reason, message, source_event_id, and provider/model when known. A trigger event — wire fallback and alert playbooks to it. |
The useful trick is that each result is itself an event under the step's id, so the next step in the same playbook reads it directly — ${steps.intent.category}, ${steps.invoice.result.supplier}, ${steps.draft.text} — with no temporary files. ai.extract is the one verb whose payload nests under result; every other fact's fields are read directly. Because these are ordinary facts on the bus, one daemon's output can also trigger another playbook, exactly as Example 3 reacts to Fact.AI.Classified and the ops playbook above reacts to Fact.AI.OperationFailed. For the full anatomy of the envelope every event shares, see The event envelope.
Like every binion, aiinjector-service runs as its own hardened systemd service under a dedicated, unprivileged aiinjectorsvc user, alongside its own Redis instance. Install the package and bring both units up together:
# Install the package and start both units
sudo apt install binions-aiinjector
sudo systemctl enable --now redis-binions-aiinjector binions-aiinjector
# Check status
systemctl status binions-aiinjector
It is a notify-type unit with a 30-second watchdog, so systemd knows when it is truly ready and restarts it if it ever stops reporting in. With the default stub provider the daemon needs no outbound network access at all; it needs outbound access to a provider's API only once you enable a real one. Check health directly over the local endpoint:
# Liveness and readiness
curl -s http://127.0.0.1:9104/health/live
curl -s http://127.0.0.1:9104/health/ready
# Prometheus-style metrics
curl -s http://127.0.0.1:9104/metrics
Getting deterministic, placeholder-looking text instead of real output? The daemon is still answering from the stub provider. Check that a real instance is configured (or that the legacy key file exists and is non-empty), and that the step's provider: — or the configured default — names the instance you intend. See AI providers for the full checklist.