# 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.

- **Status:** Available
- **Audience:** developer
- **In the app:** #/data
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/agent-tools-call-services/

## 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.py` is 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_console` runs over one `mailboxes.py` policy for both audiences, and the operator's own handlers were rewired onto it rather than left as a second implementation. `relationship_console` answers 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.

> **Note** Guardrails are re-checked after the fact on the realtime path, because the model speaks straight to the caller. That is the honest cost of the architecture, and it is why the same expression is used for detection there and prevention elsewhere.

## 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.

## Related

- [Connect Assistant](https://connectbyjbrh.com/docs/assistant/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Separating the agent's authority from the person's](https://connectbyjbrh.com/research/authority-separation/)
- [Files and data in Connect](https://connectbyjbrh.com/docs/files-data/)
- [A spreadsheet view that is not a second database](https://connectbyjbrh.com/research/grid-over-services/)

## What this page is based on

- `docs-source/sources/GENERAL.md` §4, §5 and §8 — where things live, autonomy and the Assistant's tools
- `docs-source/sources/CHANNELS.md` §1 — the send boundary, the allowance and the four mailbox routes
- `docs-source/sources/PHONE.md` §3 — the worker's lack of business logic and its authenticated door
- `docs-source/sources/GENERAL.md` §9 — the data grid over owning services
