Loquent · AI Tool Registry Mode B · research + proposal · 2026-06-14
AI Agents v3 · Tool Registry

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.

Scope · research & design — no code yet Branch · feat/agent-memory-learning-evolution-1559 Source of truth · the registry once locked
↳ The surprise up front:
  • The domain tools (contacts, messaging, calls, tasks, sales, infra) are already shared between assistant and agents — via collect_rig_tools(). There is no big "assistant-only" pile to move.
  • The real gaps are granularity (agents get domain tools all-or-nothing via one flag), ownership (the registry lives inside mods/assistant), and two parallel catalogs (AiToolName + AiAgentToolName) that don't know about each other.
  • To "do more," agents need a small set of new agent-scoped tools — chiefly schedule create/cancel — not access to assistant tools.

00TL;DR

Six things to take away before the detail.

01 · TWO CATALOGS

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.

02 · ALREADY SHARED

Domain tools reach agents today through collect_agent_domain_rig_tools(). Only one tool is truly assistant-only: set_member_personalization.

03 · ALL-OR-NOTHING

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.

04 · WRONG HOME

The registry (tool_registry_service.rs) lives in mods/assistant, yet agents depend on it. The catalog should own the gating, not a consumer.

05 · SCOPE, NOT MOVE

The fix is to tag every tool shared / agent / assistant and gate by one allowlist for all consumers — not to relocate piles of tools.

06 · THE GAP

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.

AiToolName (mods/ai/types) AiAgentToolName (mods/ai_agent/tools/mod.rs) ~38 live "domain" tools 14 "agent-control" tools │ │ ▼ ▼ ┌─────────────────────────────┐ ┌──────────────────────────────┐ │ collect_rig_tools() │ │ build_agent_tools(allowlist, │ │ in mods/assistant/services │ │ thread_id) │ │ · permission gate (ABAC) │ │ · per-name allowlist match │ │ · Family surfacing gate │ │ · curried thread context │ │ · TierToolFlags gate │ └──────────────────────────────┘ └─────────────────────────────┘ │ │ │ │ include_assistant Family::all() │ _meta = true (agents) │ │ │ │ ▼ ▼ ▼ ASSISTANT turn AGENT domain bundle + AGENT-CONTROL tools (page-surfaced) (attach_domain_tools) (tools_allowlist) └──────────┬──────────┘ ▼ one rig AgentBuilder.tools(Vec<ToolDyn>)

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 gatesurfacing_families(page_context) narrows by the page the human is on (Contacts → Contacts+Messages+Calls+Sales, etc.). A pure narrowing layer; never widens.
  • Tier gateTierToolFlags { 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);
i

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 areaRuntimeTools todayUsage feature
Assistant (human chat)mods/assistantFull domain registry, page-surfaced, + personalizationAssistant
Autonomous agents (text/voice/webchat)mods/ai_agentDomain bundle (if flag on) + agent-control allowlist + per-turnAgent
Text-reply (inbound SMS)mods/ai_agentAgent path; reply via send_sms or propose_sms_replies (send-mode gate)Agent
Widget / web-chatmods/ai_agentAgent path + send_webchat_reply relayAgent
Voice call (realtime)mods/agentNone — streaming audio, no tool callsRealtimeTurn
Plan executormods/planIts own tool set; not in prod (see memory note)ExecuteAutonomousCampaign
Briefing / extraction / enrichment / analysisvariousNone — structured-output LLM calls, no toolsseveral

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.

ScopeMeaningWho can be granted itWhy 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.
i

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)

ToolFamilyR/WNote
contact_findContactsRCORE — always surfaced
contact_queryContactsRCORE
contact_readContactsRCORE · profile + memory
contact_writeContactsWcreate / update
contact_method_writeContactsWphones / emails
contact_tag_write · get_contact_tags · add_contact_noteContactsR/Wtagging + notes
contact_messages_read · contacts_by_message_stateMessagesRhistory + triage
send_smsMessagesWCORE · agents already have this
calls_read · get_call_detailsCallsRcall history / transcript
tasks_read · task_write · set_task_statusTasksR/Wagents can already create tasks via task_write
offers_read · products_readSalesRsales catalog
get_analyticsAnalyticsRCORE · dashboard stats
notifications_read · mark_notifications_readNotificationsR/WCORE read
phones_read · phone_config_write · search_available_numbers · buy_phone_numberInfrastructureR/Wprovisioning & routing
voice_agents_read · voice_agent_write · text_agents_read · text_agent_writeInfrastructureR/Wagent config
analyzers_read · analyzer_write · get_knowledge_bases · get_prompt_writing_guideInfrastructureR/Wtier-gated
i

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)

ToolR/WBound toNote
read_my_memory · update_my_memoryR/Wthread → agentlong-term memory blocks (this branch)
read_contact_memory · update_contact_memoryR/Wthread → agent + contactper-contact facts (this branch)
invoke_agent · enqueue_child · enqueue_parentWthread ancestryagent-to-agent messaging
create_agent · delete_agent · modify_agent_tools · modify_agent_skillsWowning userself / fleet management
escalate_to_user · resolve_escalationWthread → ownerask the human; record the answer as a lesson
list_my_skills · load_skillRthread → agentlazy-skill index
create_schedule · cancel_schedule · list_my_schedulesW/Rthread → agentnew — §6
record_lesson / reflect_nowWthread → agentnew — optional, §6

assistant — human-session tools

ToolR/WNote
set_member_personalizationWThe genuine assistant-only tool today (gated by include_assistant_meta).
delegate_bulk_operationWCurrently 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.

┌──────────────────────────────────────────────────────────────┐ │ mods/ai/rig/registry (neutral — both consumers already │ │ depend on mods/ai) │ │ │ │ AiToolName (unified catalog; AiAgentToolName folded in) │ │ • api_name() • display_label() • from_api_name() │ │ • scope() -> ToolScope { Shared | Agent | Assistant } │ ← NEW │ • build(ctx) -> Box<dyn ToolDyn> │ │ │ │ build_tools(allowlist, ctx, scope_filter) -> Vec<ToolDyn> │ │ 1. resolve each name -> AiToolName │ │ 2. drop if tool.scope() not allowed for this consumer │ │ 3. drop if ABAC permission fails (shared tools) │ │ 4. drop if tier flag off │ │ 5. build(ctx) — ctx carries Session AND/OR thread_id │ └──────────────────────────────────────────────────────────────┘ ▲ ▲ ▲ │ allowlist = │ allowlist = │ allowlist = │ page-derived │ agent.tools_allowlist │ channel default │ default │ (+ domain subset) │ ASSISTANT AUTONOMOUS AGENT TEXT-REPLY / WIDGET scope_filter = scope_filter = scope_filter = {Shared, Assistant} {Shared, Agent} {Shared, Agent}

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 toolScopeBacking serviceStatus
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
i

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

  • ScheduleRecurrence has four variants: Hourly, Daily, Weekdays, Weekly. All recur. There is no "once, at this datetime" option.
  • poll_due_agent_schedules() runs every minute, evaluates is_due_at(hour, minute, weekday) in org-local time, and on a hit wakes the agent via find_or_spawn_agent_thread with an AiThreadEventPayload::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

ACT 1 — today, on a live thread ──────────────────────────────── Contact: "call me tomorrow to confirm the 3pm slot" Agent → create_schedule({ ✗ TOOL MISSING recurrence: Once { at: tomorrow 09:00 }, ✗ VARIANT MISSING instruction: "Text Jane to confirm her 3pm slot", contact_id: jane ✗ PAYLOAD CAN'T CARRY IT }) Agent → "Done — I'll follow up tomorrow morning." ACT 2 — tomorrow 09:00, org-local ──────────────────────────────── poll_due_agent_schedules() ✓ EXISTS → is_due_at(9,0,Wed) hit ✓ EXISTS → find_or_spawn_agent_thread(payload: Scheduled{instruction}) ✓ EXISTS → agent wakes, reads instruction Agent → send_sms(to: jane, "Hi Jane, confirming your 3pm…") ✓ EXISTS Agent → cancel_schedule(this_one) ✗ TOOL MISSING (or: Once auto-disables after firing) ✗ VARIANT MISSING

The four concrete changes

  1. New tool create_schedule agent — thin wrapper over create_ai_agent_schedule, curried with caller_thread_id → agent_id. Input: recurrence + instruction (+ optional contact_id).
  2. 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.
  3. New recurrence Once { at: DateTime } — fires a single time then auto-disables (cleanest UX), or rely on the agent calling cancel_schedule in Act 2 (no schema change, but fragile if the agent forgets). Recommend the auto-disabling Once variant; cancel_schedule stays useful for recurring schedules.
  4. Carry contact context into the wake — either extend AiThreadScheduled with an optional contact_id (and surface · id {contact_id} in the frame, mirroring inbound events so update_contact_memory / send_sms are constructible from the turn alone), or require the agent to put the contact reference in the instruction text and re-resolve via contact_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.

Q1

Where does the unified registry live?

  • 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_tools module.
Q2

One enum or two?

  • Keep two enums but add a shared ToolScope trait both implement — less churn, but the "two catalogs" problem (P3) lingers.
Q3

Does attach_domain_tools survive?

  • Drop it; migrate every agent's flag into explicit allowlist entries — cleaner model, one-time migration of existing agent rows.
Q4

How autonomous is create_schedule?

  • 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.
Q5

Is delegate_bulk_operation shared or assistant-only?

  • 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() and AiAgentToolName::scope(). No behaviour change — just makes scope explicit and reviewable. Unblocks everything else.

  • P1Unify the registryL

    Move collect_rig_tools into the neutral home (Q1), introduce ToolContext + 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_tools into a recommended shared subset (Q3); let an agent's tools_allowlist name individual domain tools. Update the capabilities UI so owners pick exactly what an agent may do.

  • P3Schedule toolsM

    create_schedule / cancel_schedule / list_my_schedules over the existing services + the Once recurrence 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 confirm set_member_personalization stays assistant-only under the new scope filter.

  • P5Optional: learning & planning toolsLATER

    record_lesson / reflect_now over run_learning_digest, and eventually present_plan for #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.