One registry for every AI tool
Today the assistant and the autonomous agents pull tools from two different mechanisms wired into two different modules. This doc maps what exists, proposes a single scope-tagged registry built on the agent's allowlist pattern, and lists the tools agents are still missing to act over time — like scheduling a follow-up tomorrow and closing it once it sends.
00TL;DR
Six things to take away before the detail.
AiToolName (~38 live domain tools, in mods/ai) and AiAgentToolName (14 agent-control tools, in mods/ai_agent). Two enums, two builders, two gating styles.
Domain tools reach agents today through collect_agent_domain_rig_tools(). Only one tool is truly assistant-only: set_member_personalization.
An agent's domain access is one boolean — attach_domain_tools. It can't be configured tool-by-tool the way agent-control tools are via tools_allowlist.
The registry (tool_registry_service.rs) lives in mods/assistant, yet agents depend on it. The catalog should own the gating, not a consumer.
The fix is to tag every tool shared / agent / assistant and gate by one allowlist for all consumers — not to relocate piles of tools.
Agents can already send SMS & create tasks. What they can't do: schedule a future action and close it. That needs ~3 new agent-scoped tools + a one-shot recurrence.
01The system today
Three mechanisms decide which tools an LLM turn can see. They were built at different times and don't share a spine.
Mechanism A — the shared domain registry
Lives in src/mods/assistant/services/tool_registry_service.rs. The private collect_rig_tools(session, families, page_context, conversation_id, tier_flags, include_assistant_meta) applies three gates in order:
- Permission gate (ABAC) —
has_any_of(session, &[Permission::…]); super-admins / owners bypass. - Surfacing gate —
surfacing_families(page_context)narrows by the page the human is on (Contacts → Contacts+Messages+Calls+Sales, etc.). A pure narrowing layer; never widens. - Tier gate —
TierToolFlags { knowledge_base_enabled, custom_analyzers_enabled, autonomous_plans_enabled }gates a few infra tools.
Two public entry points wrap it:
// Assistant: page-surfaced + the personalization meta-tool
pub fn collect_assistant_rig_tools(session, page_context, conversation_id, tier_flags)
-> collect_rig_tools(session, &surfacing_families(page_context), …, true);
// Agent: ALL families, NO page surfacing, NO personalization
pub fn collect_agent_domain_rig_tools(session, tier_flags)
-> collect_rig_tools(session, &Family::all(), None, None, tier_flags, false);
The only thing include_assistant_meta gates
One tool: set_member_personalization (the human's "remember how I like things" session tool). Everything else in the domain catalog is reachable by an agent that has attach_domain_tools = true and the matching ABAC permission.
Mechanism B — the agent-control registry
Lives in src/mods/ai_agent/tools/mod.rs. build_agent_tools(allowlist, caller_thread_id) iterates the agent's stored tools_allowlist and matches each name against AiAgentToolName. This is the granular, per-tool model the user wants to generalise. The 14 tools cover memory, agent-to-agent messaging, self-management, escalation and skills — all curried with caller_thread_id and resolving agent/org identity from the thread.
Mechanism C — per-turn conditional tools
Some tools aren't in either gate — the turn runner injects them by situation: list_my_skills / load_skill (lazy-skill mode), resolve_escalation (only when the thread has open escalations), and the channel-bound propose_sms_replies / propose_webchat_replies / send_webchat_reply (curried with the live contact/message/session).
Where the other AI areas stand
| AI area | Runtime | Tools today | Usage feature |
|---|---|---|---|
| Assistant (human chat) | mods/assistant | Full domain registry, page-surfaced, + personalization | Assistant |
| Autonomous agents (text/voice/webchat) | mods/ai_agent | Domain bundle (if flag on) + agent-control allowlist + per-turn | Agent |
| Text-reply (inbound SMS) | mods/ai_agent | Agent path; reply via send_sms or propose_sms_replies (send-mode gate) | Agent |
| Widget / web-chat | mods/ai_agent | Agent path + send_webchat_reply relay | Agent |
| Voice call (realtime) | mods/agent | None — streaming audio, no tool calls | RealtimeTurn |
| Plan executor | mods/plan | Its own tool set; not in prod (see memory note) | ExecuteAutonomousCampaign |
| Briefing / extraction / enrichment / analysis | various | None — structured-output LLM calls, no tools | several |
So the consumers that genuinely benefit from a shared tool catalog are: assistant, agents, text-reply, widget, and (later) voice if it gains tool use. That's the "other AI areas might use them" the brief refers to.
02What's actually wrong
Not "tools are trapped in the assistant" — they aren't. The friction is structural.
P1 · Domain access is one boolean, not a list
An agent either gets the entire domain bundle (attach_domain_tools = true) or none of it. You can't say "this agent may read contacts and send SMS, but never buy phone numbers or rewrite other agents' configs." Agent-control tools already support exactly that via tools_allowlist — domain tools should too.
P2 · The registry lives in a consumer
collect_rig_tools sits in mods/assistant/services, but mods/ai_agent reaches into it. The catalog (AiToolName) and the rig tool defs already live in the neutral mods/ai; the gating is the one piece stranded in a consumer module.
P3 · Two catalogs that can't see each other
AiToolName and AiAgentToolName are separate enums with separate api_name() / display_label() / from_api_name() impls. A single allowlist string like "send_sms" resolves in one and "escalate_to_user" in the other — the runtime has to try both. There is no one place that answers "what tools exist, and who may use each?"
P4 · "Assistant-only" vs "agent-only" is implicit
The split lives in code paths (include_assistant_meta, the per-turn injections, the curried-context tools), not in a declared property on each tool. A reviewer can't glance at a tool and know its scope.
03Three tool scopes (the core proposal)
Make scope an explicit property of every tool. The brief's "share some, keep some agent-only" becomes a declared tag, not a code path.
| Scope | Meaning | Who can be granted it | Why it's scoped this way |
|---|---|---|---|
| shared | Domain capability over org data — contacts, messaging, calls, tasks, sales, analytics, infra config. | Any AI area: assistant, agents, text-reply, widget, voice. | Scoped by capability. Needs only a Session + ABAC permission; no thread identity required. |
| agent | An autonomous agent acting as itself over time — memory, schedules, agent-to-agent, escalation, self-management, skills. | Autonomous agents only. | Scoped by binding. Curried with caller_thread_id; resolves a persistent agent identity. Meaningless without a thread + a durable agent row. |
| assistant | Helps the live human session itself — personalization, delegating a draft bulk op back to the human UI. | The human assistant only. | Scoped by actor. Operates on the human's session/UI; an autonomous agent has no human session to personalize. |
| channel optional 4th |
Reply on the exact live channel of this turn — propose_sms_replies, send_webchat_reply. |
Whichever agent turn owns the live channel context. | Curried with the live message_id/session_id. Can't be a static allowlist entry; the turn runner injects it. Could stay "per-turn" rather than become a formal scope. |
The useful distinction: scoped-by-capability vs scoped-by-binding
A tool is agent-only not because of what it does but because of how it's wired — it's curried with the thread and resolves "me, the agent." That's why you can't simply hand update_my_memory to the assistant: there's no agent identity behind a human chat. Recognising this tells you which tools can ever be promoted to shared (capability tools) and which never can (binding tools).
04Every tool, classified
The full inventory tagged by proposed scope. new marks tools that don't exist yet (§6).
shared — domain tools (any AI area)
| Tool | Family | R/W | Note |
|---|---|---|---|
| contact_find | Contacts | R | CORE — always surfaced |
| contact_query | Contacts | R | CORE |
| contact_read | Contacts | R | CORE · profile + memory |
| contact_write | Contacts | W | create / update |
| contact_method_write | Contacts | W | phones / emails |
| contact_tag_write · get_contact_tags · add_contact_note | Contacts | R/W | tagging + notes |
| contact_messages_read · contacts_by_message_state | Messages | R | history + triage |
| send_sms | Messages | W | CORE · agents already have this |
| calls_read · get_call_details | Calls | R | call history / transcript |
| tasks_read · task_write · set_task_status | Tasks | R/W | agents can already create tasks via task_write |
| offers_read · products_read | Sales | R | sales catalog |
| get_analytics | Analytics | R | CORE · dashboard stats |
| notifications_read · mark_notifications_read | Notifications | R/W | CORE read |
| phones_read · phone_config_write · search_available_numbers · buy_phone_number | Infrastructure | R/W | provisioning & routing |
| voice_agents_read · voice_agent_write · text_agents_read · text_agent_write | Infrastructure | R/W | agent config |
| analyzers_read · analyzer_write · get_knowledge_bases · get_prompt_writing_guide | Infrastructure | R/W | tier-gated |
Infra tools are "shared" but should rarely be granted to agents
Letting an autonomous agent buy phone numbers or rewrite other agents is a powerful default. They stay shared (capability-only, no thread binding) but the recommended agent allowlist should exclude them unless the owner opts in. Scope = "who can"; allowlist = "who does."
agent — agent-control tools (autonomous only)
| Tool | R/W | Bound to | Note |
|---|---|---|---|
| read_my_memory · update_my_memory | R/W | thread → agent | long-term memory blocks (this branch) |
| read_contact_memory · update_contact_memory | R/W | thread → agent + contact | per-contact facts (this branch) |
| invoke_agent · enqueue_child · enqueue_parent | W | thread ancestry | agent-to-agent messaging |
| create_agent · delete_agent · modify_agent_tools · modify_agent_skills | W | owning user | self / fleet management |
| escalate_to_user · resolve_escalation | W | thread → owner | ask the human; record the answer as a lesson |
| list_my_skills · load_skill | R | thread → agent | lazy-skill index |
| create_schedule · cancel_schedule · list_my_schedules | W/R | thread → agent | new — §6 |
| record_lesson / reflect_now | W | thread → agent | new — optional, §6 |
assistant — human-session tools
| Tool | R/W | Note |
|---|---|---|
| set_member_personalization | W | The genuine assistant-only tool today (gated by include_assistant_meta). |
| delegate_bulk_operation | W | Currently CORE / shared, but it drafts a bulk op for the human to approve in the assistant UI. Reclassify to assistant-only? — see decision Q3. |
channel — per-turn injected (stays dynamic)
propose_sms_replies, propose_webchat_replies, send_webchat_reply. Curried with the live channel context, so they can't be static allowlist entries — the turn runner keeps injecting them by situation regardless of the registry refactor.
05Target architecture
One catalog, one builder, scope declared per tool, one granular allowlist for every consumer. "Follow the agent registry pattern" — generalised.
Today
- Agent domain access = one bool
attach_domain_tools - Gating in
mods/assistant - Two enums, runtime tries both
- Scope implied by code path
- Assistant surfacing logic is bespoke
Target
- Agent domain access = named entries in
tools_allowlist - Gating in neutral
mods/ai - One catalog with
scope() - Scope is a declared method per tool
- Assistant just passes a page-derived default allowlist +
{Shared, Assistant}filter
What "context" (ctx) carries
The one wrinkle: shared tools need a Session; agent tools need a caller_thread_id. The unified builder takes a ToolContext that can hold either or both. A tool's build(ctx) pulls what it needs; the builder asserts the required field is present (an agent-scoped tool with no thread id is a programming error, not a runtime grant).
pub struct ToolContext {
pub session: Option<Session>, // shared tools (ABAC)
pub caller_thread_id: Option<Uuid>, // agent + channel tools
pub page_context: Option<PageContext>, // assistant surfacing hints
pub tier_flags: TierToolFlags,
}
Backward-compatible by construction
Tool api_names don't change, so stored tools_allowlist rows and replayed conversations keep resolving. attach_domain_tools = true can be kept as sugar that expands to "the recommended shared subset" in the allowlist — no forced migration of existing agents.
06Missing tools — let agents do more
Given the schedule poller (#1531), escalation (#1543) and the learning/memory work on this branch, here's what an agent still can't reach — ranked by how much it unlocks.
What agents can already do (so we don't rebuild it)
- shared
send_sms— send a message now (CORE domain tool). - shared
task_write— create a follow-up task for the owner. - agent
update_contact_memory/update_my_memory— remember what happened. - agent
escalate_to_user/resolve_escalation— ask the human and learn from the answer.
The real gaps
| Proposed tool | Scope | Backing service | Status |
|---|---|---|---|
| create_schedule | agent | create_ai_agent_schedule(db, agent_id, input) |
service exists wrap as tool |
| cancel_schedule | agent | delete_ai_agent_schedule / set_ai_agent_schedule_enabled(…, false) |
service exists wrap as tool |
| list_my_schedules | agent | schedule list query | service exists so the agent can see & close its own |
| ScheduleRecurrence::Once | — | recurrence enum + cron / due check | new variant one-shot is impossible today (§7) |
| scheduled-wake contact context | — | AiThreadScheduled payload |
type gap carries only an instruction string, no contact id (§7) |
| record_lesson / reflect_now | agent | run_learning_digest(db, agent_id) + lesson append |
service exists optional — digest is poller/manual-only today |
| present_plan | agent | long-horizon planning (#1479) | not built future — research→plan→approve→execute |
Headline finding
"Schedule a follow-up tomorrow, then close it once it sends" is ~90% existing infrastructure. The poller fires due schedules; send_sms exists. The missing pieces are small and specific: a tool to create a schedule, a tool to cancel it, a one-shot recurrence (or self-cancel after fire), and a way to carry the contact into the scheduled wake. See §7 for the full trace.
07The follow-up example, in full
Tracing "tomorrow at 9am, text Jane to confirm; then close the schedule" against today's code reveals exactly the four gaps.
What the schedule system gives us today
ScheduleRecurrencehas four variants:Hourly,Daily,Weekdays,Weekly. All recur. There is no "once, at this datetime" option.poll_due_agent_schedules()runs every minute, evaluatesis_due_at(hour, minute, weekday)in org-local time, and on a hit wakes the agent viafind_or_spawn_agent_threadwith anAiThreadEventPayload::Scheduled { instruction }.- That payload carries only a string. It is framed to the model as
[Scheduled instruction from your owner — not the contact]with no contact id — unlike inbound events, which carry· id {contact_id}. - Schedules are created through the management UI APIs (
create_ai_agent_schedule_api…). No tool exposes this to the agent.
The desired sequence — and where it breaks
The four concrete changes
- New tool create_schedule agent — thin wrapper over
create_ai_agent_schedule, curried withcaller_thread_id→ agent_id. Input: recurrence + instruction (+ optional contact_id). - New tool cancel_schedule / list_my_schedules agent — so the agent can close a one-shot after it fires, or clean up obsolete recurring ones. Wraps
delete_ai_agent_schedule/set_…_enabled. - New recurrence Once { at: DateTime } — fires a single time then auto-disables (cleanest UX), or rely on the agent calling
cancel_schedulein Act 2 (no schema change, but fragile if the agent forgets). Recommend the auto-disablingOncevariant;cancel_schedulestays useful for recurring schedules. - Carry contact context into the wake — either extend
AiThreadScheduledwith an optionalcontact_id(and surface· id {contact_id}in the frame, mirroring inbound events soupdate_contact_memory/send_smsare constructible from the turn alone), or require the agent to put the contact reference in theinstructiontext and re-resolve viacontact_find. The former is cleaner and matches the #1494 Part-B pattern already in the payload type.
Trust note — schedules are a powerful capability
A scheduled wake is framed as an owner instruction ("from your owner — not the contact"). If a contact can talk an agent into creating one, a contact effectively authors a future "owner" instruction. create_schedule should (a) be off by default in the recommended allowlist, (b) cap pending schedules per agent like escalations cap at 5, and (c) consider routing contact-triggered schedules through the existing approval surface rather than firing autonomously. Worth a decision (Q4).
08Open decisions
Five choices that change the shape of the work. My recommendation is marked; push back freely — that's what this doc is for.
Where does the unified registry live?
- Neutral
mods/ai/rig/registry— both assistant & ai_agent already depend onmods/ai; keeps the catalog out of any consumer. - Inside
mods/ai_agent(literally "the agent registry") — matches the brief's wording but makes the assistant depend on the agent module. - A new top-level
mods/ai_toolsmodule.
One enum or two?
- Fold
AiAgentToolNameintoAiToolNamewith ascope()method — onefrom_api_name, one catalog, the runtime stops guessing. - Keep two enums but add a shared
ToolScopetrait both implement — less churn, but the "two catalogs" problem (P3) lingers.
Does attach_domain_tools survive?
- Keep it as sugar that expands to the recommended shared subset in the allowlist — zero migration, owners keep the simple toggle, power users get granularity.
- Drop it; migrate every agent's flag into explicit allowlist entries — cleaner model, one-time migration of existing agent rows.
How autonomous is create_schedule?
- Allowed but off-by-default in the recommended allowlist, capped per agent, contact-triggered ones flagged for owner review.
- Fully autonomous once the owner enables the tool — simpler, but a contact can author a future "owner" instruction.
- Schedules can only be proposed (like SMS drafts), never created autonomously.
Is delegate_bulk_operation shared or assistant-only?
- Reclassify to assistant — it draws a draft into the human assistant's review UI; an autonomous agent has no such surface. (Verify nothing in the agent path relies on it first.)
- Leave it shared/CORE — but then define what "delegate a bulk op" means for a headless agent.
09Suggested build order
Sequenced so each phase compiles green and ships value on its own. Pill = rough size.
-
P0Declare scopeS
Add
ToolScope+AiToolName::scope()andAiAgentToolName::scope(). No behaviour change — just makes scope explicit and reviewable. Unblocks everything else. -
P1Unify the registryL
Move
collect_rig_toolsinto the neutral home (Q1), introduceToolContext+build_tools(allowlist, ctx, scope_filter), fold the two catalogs (Q2). Assistant and agent paths become thin callers. Behaviour preserved; tests pin the same tool sets. -
P2Granular domain allowlistM
Expand
attach_domain_toolsinto a recommended shared subset (Q3); let an agent'stools_allowlistname individual domain tools. Update the capabilities UI so owners pick exactly what an agent may do. -
P3Schedule toolsM
create_schedule/cancel_schedule/list_my_schedulesover the existing services + theOncerecurrence variant + scheduled-wake contact context. Delivers the headline use case. Gate behind Q4's trust rules. -
P4Reclassify the straysS
Settle
delegate_bulk_operation(Q5) and confirmset_member_personalizationstays assistant-only under the new scope filter. -
P5Optional: learning & planning toolsLATER
record_lesson/reflect_nowoverrun_learning_digest, and eventuallypresent_planfor #1479. Defer until P0–P3 are proven.
Smallest valuable slice
If you only want the follow-up capability and not the full refactor: P3 alone ships it (three thin tool wrappers + one enum variant + one optional payload field), riding entirely on the schedule poller and send_sms that already exist. P0–P2 are the structural cleanup the brief is really asking for.