Agent
Tool registry
Every user-facing write path passes through exactly one tool.
Every mutation in SOMA flows through a Mastra tool. UIs and bots call server actions; server actions call tools; tools write to Postgres. No other write path exists.
Tool names on the wire
Anthropic's tool-name pattern is ^[a-zA-Z0-9_-]{1,128}$ — dots are rejected. So the registry uses underscored names:
| Prose / docs | On the wire |
|---|---|
memory.capture | memory_capture |
memory.recall | memory_recall |
memory.relate | memory_relate |
memory.update | memory_update |
memory.forget | memory_forget |
search.entities | search_entities |
schedule.create | schedule_create |
graph.neighbors | graph_neighbors |
The dotted form lives on in prose and system prompts for readability.
The registry
| Domain | Tool | Input → Output | Writes |
|---|---|---|---|
| memory | memory_capture | {type, name, properties} → {entityId, created} | entities · sources |
memory_recall | {query, limit?} → [{entity, score}] | read-only | |
memory_relate | {fromId, toId, type, properties?} → {edgeId} | edges | |
memory_update | {entityId, properties} → {updated} | entities | |
memory_forget | {entityId} → {archived} | entities.status | |
| search | search_entities | {query} → [entity] lexical | read-only |
| graph | graph_neighbors | {entityId, depth, edgeTypes?} → {nodes, edges} | read-only |
| schedule | schedule_create | {name, startsAt, endsAt, pushToGCal?} → {eventId} | events · optional GCal |
| external | Gmail / GCal / Slack / Apple Health ingest helpers | OAuth-scoped | sources · domain tables |
Shared helpers
Under @soma/tools/shared/:
embed/embedQuery/embedMany— Voyage-3-large wrapperrerank— Voyage rerank-2, falls back to original order on API failuretranscribe— Whisper wrapper for voice notesencryptToken/decryptToken— AES-GCM-256 withOAUTH_ENCRYPTION_KEY
Writing a new tool
See soma-tool-author Claude skill in .claude/skills/soma-tool-author/. The shape:
// packages/tools/src/<domain>/<verb>.ts
import { createTool } from '@mastra/core';
import { z } from 'zod';
const Input = z.object({
/* ... */
});
const Output = z.object({
/* ... */
});
export const myTool = createTool({
id: 'domain_verb', // underscored — Anthropic won't accept dots
description: '...', // first sentence matters — it's what the LLM reads
inputSchema: Input,
outputSchema: Output,
execute: async ({ context, resourceId }) => {
if (!resourceId) throw new ToolError('resourceId required', 'NO_USER');
// All writes scoped by resourceId (= userId)
},
});Register it in packages/tools/src/index.ts under allTools with the same underscored key, and it's automatically wired into the agent.