Why an agent's tools should call domain services
A tool that writes to the database directly is quicker to build and removes every rule the domain service carries — autonomy, suppression, the daily allowance, the audit entry and the scope filters. Connect's 66 Assistant tools call the same services a person's click calls, and the voice worker holds no business logic at all: it reports what happened and the engine decides what it means.
Why the shortcut is tempting#
An agent tool usually needs one small thing: mark this thread read, create this follow-up, move this deal. The domain service that already does it wants a workspace, a permission check, an actor and a reason, and returns something richer than the tool needs. Writing the row directly is four lines and works immediately.
It also works immediately in testing, in a demo, and for the first fortnight in production. What it does not do is refuse, hold, record or scope — and none of those absences produce an error.
What the bypass actually skips#
| Rule | Where it lives | What its absence looks like |
|---|---|---|
| Autonomy mode and scope | autonomy.py, four scopes, narrowest wins | A reply that should have waited for approval goes out |
| Suppression, unsubscribe, do-not-contact | One compliance gate before any outreach | A message to somebody who asked never to hear from you |
| Daily allowance | metering.py; work is held, and the read cursor deliberately does not advance | Silent overshoot, or messages lost when a cursor moves past held work |
| Provider evidence | outbound.py — sent only on acknowledgement, otherwise uncertain | A row that says sent with nothing behind it |
| Workspace scope | The kernel and row-level security | A write that lands in the wrong workspace or with no stamp at all |
| Audit | audit.py — decision, rule, actor, outcome | An action nobody can account for months later |
Each of these is a rule somebody argued about once and then encoded. The bypass does not disagree with them; it simply never asks.
The shape that avoids it#
- One send boundary.
outbound.pyis used by the engine and by a person, so an agent send and a human send obey identical rules and produce identical evidence. - One policy per capability, two thin routers.
mailbox_consoleruns over onemailboxes.pypolicy for both audiences, and the operator's own handlers were rewired onto it rather than left as a second implementation.relationship_consoleanswers one relationship for either audience the same way. - The grid is not a second store. Every change made in the Excel-like data view goes through the service that owns the record, which is what stops thirteen sheets becoming a parallel CRM with its own rules.
- Tools are thin. The writing tools —
send_email,approve_draft,create_followup,move_opportunity,data_update— are names for service calls, which is why the Assistant's rights can be narrower than a person's without a second rulebook.
The voice worker, as the extreme case#
The realtime voice worker runs in its own process, on its own host, holding a live call — the strongest possible temptation to let it decide things. It holds no business logic. voice_engine.brief() renders the persona, the line's purpose, the contact's facts, knowledge, the hard rules and the supervisor's guidance once per call; the worker posts every sentence to /api/voice/engine/spoken, events to /engine/event, and /engine/end runs the same voice.end_call that the carrier path runs — summary, lead, memory, follow-up, and model usage into costs.
Outbound dialling goes through comms_providers.dispatch_call with a placer, so memory blocks, consent, autonomy and budget gate a realtime call exactly as they gate a carrier call. The worker's own door is authenticated by a token and answers 503 when it is unset rather than falling open.
Where the shape has leaked#
Four mailbox routes answered for any workspace's row: the connection form, sync, and the OAuth start and disconnect took a mailbox id from a browser and looked it up with a bare db.get — which the kernel's query filter can miss entirely when the row is already in the identity map. They go through mailbox_console.row_for now. Only the SendGrid webhook still does not, because it resolves its mailbox before any workspace is known.
- A bypass is easiest to introduce during an incident, when the service is the thing that seems to be in the way.
- An ORM identity map can defeat a query-level scope filter, so *the kernel filters everything* is true of queries and not of every access path.
- A tool surface grows faster than the rules do; 66 tools is a lot of doors, and the defence is that each is a name for a call rather than an implementation.
- No measurement exists here of how often a tool call is refused by the service it goes through: UNKNOWN, and worth counting.
Questions#
Does routing every tool through a service not make the agent slow?
The service call is not the expensive part of an agent turn — the model is. What the service adds is a permission check and an audit write against a decision that already took seconds to reach, and it removes an entire class of failure where the agent does something a person could not have done from the same screen.
What about a tool that only reads?
Read paths still need the workspace scope, which is why they go through the same kernel and the same row-level security. The distinction that matters is not read against write; it is whether the code path can be reached with a scope nobody set.
How do you stop a new tool from taking the shortcut?
By having no plausible reason to. When the service is the only thing that knows how to create a follow-up correctly — with its reason, its duplicate check and its channel rules — writing the row directly is more work, not less. Where that is not yet true, the service is the thing to fix.