SOMA docs
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 / docsOn the wire
memory.capturememory_capture
memory.recallmemory_recall
memory.relatememory_relate
memory.updatememory_update
memory.forgetmemory_forget
search.entitiessearch_entities
schedule.createschedule_create
graph.neighborsgraph_neighbors

The dotted form lives on in prose and system prompts for readability.

The registry

DomainToolInput → OutputWrites
memorymemory_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
searchsearch_entities{query}[entity] lexicalread-only
graphgraph_neighbors{entityId, depth, edgeTypes?}{nodes, edges}read-only
scheduleschedule_create{name, startsAt, endsAt, pushToGCal?}{eventId}events · optional GCal
externalGmail / GCal / Slack / Apple Health ingest helpersOAuth-scopedsources · domain tables

Shared helpers

Under @soma/tools/shared/:

  • embed / embedQuery / embedMany — Voyage-3-large wrapper
  • rerank — Voyage rerank-2, falls back to original order on API failure
  • transcribe — Whisper wrapper for voice notes
  • encryptToken / decryptToken — AES-GCM-256 with OAUTH_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.