Multi-agent support for Signet
Context
Solvr deployment needs 3 agents (Dot, Rose, Miles) running on a single Mac Studio with one Signet daemon and one OpenClaw gateway. Today Signet is single-agent only — one identity, one memory pool, one personality per ~/.agents/ install. OpenClaw already supports multi-agent natively (agents.list + bindings in openclaw.json), so the gap is entirely on the Signet side.
User decisions:
- Memory: Hybrid — single SQLite DB, daemon enforces scope at API level (agent_id column + scope flag: global vs private)
- Directory:
~/.agents/agents/{name}/subdirectories, each with their own identity files (SOUL.md, IDENTITY.md, etc.) - Skills: Shared pool at
~/.agents/skills/, per-agent allowlist in agent.yaml - Harness scope: OpenClaw + Compass only. Claude Code stays single-agent (it’s the operator’s personal agent).
Critical files
packages/daemon/src/daemon.ts— HTTP server, memory schema, file watcher, harness sync (~4650 LOC)packages/daemon/src/hooks.ts— Request/response interfacespackages/daemon/src/memory-config.ts— Config loadingpackages/core/src/types.ts— AgentManifest, Memory typespackages/core/src/identity.ts— Identity file detection, IDENTITY_FILES spec (line 56-97), loadIdentityFiles (line 206)packages/core/src/skills.ts— Skill registry, unificationpackages/cli/src/cli.ts— CLI commands (~4650 LOC)packages/cli/dashboard/src/— SvelteKit dashboardpackages/connector-openclaw/src/index.ts— OpenClaw syncpackages/adapters/openclaw/src/index.ts— Runtime plugin~/.agents/agent.yaml— User config manifest
Implementation phases
Phase 1: Core types — AgentDefinition + roster
File: packages/core/src/types.ts
interface AgentDefinition {
readonly name: string;
readonly model?: string;
readonly skills?: readonly string[];
readonly personality?: string; // relative path to SOUL.md
}
Extend AgentManifest with optional agents field:
readonly agents?: {
readonly roster: readonly AgentDefinition[];
};
Phase 2: Agent registry — discovery + scaffold + inheritance
New file: packages/core/src/agents.ts
discoverAgents(agentsDir)— scan~/.agents/agents/*/for subdirs, return AgentDefinition[]scaffoldAgent(name, agentsDir)— create directory + template SOUL.md, IDENTITY.mdgetAgentIdentityFiles(name, agentsDir)— resolve identity file paths with inheritance fallback: check agent subdir first, fall back to root-level~/.agents/for any missing file. Only SOUL.md and IDENTITY.md are expected to be overridden per agent. AGENTS.md, USER.md, TOOLS.md inherit from root by default.resolveAgentSkills(agent, sharedSkills)— filter shared pool by agent’s allowlist. Empty/missing list = all skills.
Identity inheritance (addresses current gap in identity.ts where loadIdentityFiles only checks one basePath):
resolve(file, agentName):
1. ~/.agents/agents/{agentName}/{file} ← agent override
2. ~/.agents/{file} ← root default
Phase 3: DB schema — add agent scoping columns
File: packages/daemon/src/daemon.ts
Add to requiredColumns array at line ~4509:
{ table: 'memories', column: 'agent_id', type: 'TEXT DEFAULT "default"' }
{ table: 'memories', column: 'scope', type: 'TEXT DEFAULT "global"' }
Existing memories get agent_id=‘default’, scope=‘global’. Fully backwards compatible via ALTER TABLE — no migration.
FTS5 handling — the FTS5 virtual table does NOT get an agent_id column. Instead, agent scoping is done as a post-join filter on the memories table after the FTS5 MATCH:
SELECT m.* FROM memories_fts
JOIN memories m ON memories_fts.rowid = m.rowid
WHERE memories_fts MATCH ?
AND (m.scope = 'global' OR m.agent_id = ?)
ORDER BY bm25(memories_fts) LIMIT ?
This avoids rebuilding the FTS5 virtual table and its 3 triggers (INSERT/UPDATE/DELETE at lines 4560-4584), which would be a breaking migration for existing databases. The post-join filter is slightly less efficient but the memories table is small enough (3441 rows currently) that it won’t matter.
Vector search (sqlite-vec) already joins through the embeddings table, so adding the same WHERE clause to the final JOIN is trivial.
Orphaned memory cleanup: When an agent is removed via CLI,
don’t auto-delete memories. Instead mark them scope=‘archived’.
A signet agent purge <name> command explicitly deletes.
Phase 4: Daemon API — scoped memory + agent endpoints
File: packages/daemon/src/daemon.ts
a) Helper: buildAgentScopeClause(agentId?) — returns SQL
WHERE fragment. If agentId is provided, returns memories
where scope=‘global’ OR agent_id matches. If omitted,
returns only scope=‘global’ (safe default).
b) Update /memory/search (line ~1300), /api/memories,
remember/recall handlers to accept agentId query param.
c) New endpoints:
GET /api/agents— list discovered agents + rosterGET /api/agents/:name— agent detail, resolved identity files, effective skillsPOST /api/agents— scaffold new agentDELETE /api/agents/:name— archive agent (marks memories, doesn’t delete directory — CLI handles that)
File: packages/daemon/src/hooks.ts
Add to RememberRequest and RecallRequest:
agentId?: string;
scope?: 'global' | 'private';
(SessionStartRequest already has agentId.)
Phase 5: File watcher — watch agent subdirectories
File: packages/daemon/src/daemon.ts, startFileWatcher()
Add ~/.agents/agents/ to chokidar watch paths. On change
inside an agent subdir, trigger sync for that agent only
(not a full harness rebuild). Parse the agent name from the
changed file path.
Phase 6: Harness sync — per-agent OpenClaw workspaces
File: packages/daemon/src/daemon.ts
New function syncAgentsToOpenClaw() called from
syncHarnessConfigs() (line ~3750):
For each agent in the roster:
- Resolve merged identity (root defaults + agent overrides)
- Write assembled AGENTS.md to a per-agent workspace dir
at
~/.agents/agents/{name}/workspace/ - Call connector to patch openclaw.json (see Phase 8)
Phase 7: CLI — signet agent commands
File: packages/cli/src/cli.ts
New subcommand group:
signet agent list— discovered agents + roster statussignet agent add <name>— scaffold, prompt for SOUL.md personality description, add to roster in agent.yamlsignet agent remove <name>— archive memories, remove from roster, optionally delete directorysignet agent purge <name>— permanently delete agent memories and directorysignet agent info <name>— resolved identity, skills, memory stats
Add --agent <name> flag to:
signet remember— sets agent_id + scope on memorysignet recall— scopes search to agent’s visible memories
Phase 8: OpenClaw connector — multi-agent sync
File: packages/connector-openclaw/src/index.ts
New syncMultipleAgents(roster, basePath) method:
Currently the connector patches agents.defaults.workspace
to ~/.agents (line 257-275, deep merge). For multi-agent:
a) Build agents.list array from roster:
{
agents: {
defaults: { workspace: basePath },
list: roster.map(agent => ({
id: agent.name,
name: agent.name,
workspace: join(basePath, 'agents', agent.name, 'workspace'),
skills: agent.skills ?? undefined,
identity: {
// resolved SOUL.md content for this agent
}
}))
}
}
b) Optionally generate bindings array if user provides
channel routing config in agent.yaml.
c) Keep the deep-merge pattern — idempotent, safe to re-run.
File: packages/adapters/openclaw/src/index.ts
The adapter already accepts agentId on onSessionStart()
(line 66-100). The gap is that createPlugin() (line 274)
doesn’t extract agentId from the OpenClaw session context.
Fix: read ctx.agentId (provided by OpenClaw when it
resolves which agent handles the current chat) and pass it
through to all daemon calls.
Phase 9: Dashboard — agent roster view
File: packages/cli/dashboard/src/
a) New API functions in lib/api.ts:
listAgents()→ GET /api/agentsgetAgent(name)→ GET /api/agents/:namecreateAgent(name)→ POST /api/agents
b) Add agent context to LeftSidebar.svelte (section 01, currently just “Identity”). Show a dropdown/switcher listing discovered agents. Selected agent scopes the Memory tab’s queries.
c) New “Agents” tab in center panel (7th tab alongside Config, Memory, Embeddings, Logs, Secrets, Skills):
- Agent cards showing name, personality summary, skill count, memory count
- Add/remove agent actions
- Per-agent memory stats
d) Update +page.ts data loader to fetch agent list on
initial load.
Phase 10: Setup wizard — multi-agent configuration
File: packages/cli/src/cli.ts, setupWizard() (line 1413)
Add a new path in the existing setup wizard decision tree:
After detecting an existing Signet install (line 1447), add option 7: “Configure multiple agents”. This launches a sub-wizard:
- “How many agents?” → prompt for count
- For each agent: name, personality description (writes SOUL.md), skill allowlist (from available skills)
- “Configure OpenClaw routing?” → optional bindings setup
- Writes agents.roster to agent.yaml
- Scaffolds directories, triggers sync
For fresh installs (line 1552), after single-agent identity creation, ask: “Set up additional agents?” → enters the same sub-wizard.
Updates and migrations
Existing installs upgrading to multi-agent Signet:
- DB columns added via
requiredColumnsALTER TABLE — this is the established live-upgrade pattern (daemon.ts:4509). No migration script needed. Existing memories get agent_id=‘default’, scope=‘global’ automatically. - FTS5 virtual table is NOT modified — no rebuild needed.
- agent.yaml gains optional
agents.rosterfield — old configs without it continue working (single-agent mode). - The
signet setupwizard detects whether agents.roster exists and shows appropriate options. - Dashboard gracefully handles zero agents (shows single identity view as today).
Version compatibility: The daemon serves both single-agent and multi-agent installs. All new API params are optional with sensible defaults (no agentId = default agent = current single-agent behavior).
Update checker (daemon.ts:2652-2710) is unaffected — it checks npm/GitHub for new versions, not schema changes.
Edge cases
FTS5 + agent scoping: Post-join filter instead of FTS5 column. Slightly less efficient but avoids breaking migration. At 3441 memories this is negligible. If scale becomes an issue later, rebuild FTS5 with agent_id column behind a flag.
Concurrent agent writes: SQLite WAL mode + busy_timeout=5s (daemon.ts:1098) handles this. Two agents writing simultaneously: one waits up to 5s. At expected write volumes (a few memories per session) this is fine. Not a hot path.
Deleted agent with orphaned memories: Memories are NOT
auto-deleted when an agent is removed. They’re marked
scope=‘archived’ and excluded from search. Explicit
signet agent purge deletes permanently. This prevents
accidental data loss.
Agent with no SOUL.md override: Falls back to root
~/.agents/SOUL.md via inheritance chain. Agent still works
but has the default personality. signet agent info shows
which files are inherited vs overridden.
Empty skills allowlist: Means “all skills” (same as
OpenClaw convention where omitting skills = all). An
explicit empty array skills: [] means no skills.
Config validation: agent.yaml parsing currently has no
schema validation (memory-config.ts uses unsafe casts, yaml.ts
silently produces NaN). This is a pre-existing issue, not
caused by multi-agent. We should add basic validation for the
new agents.roster field (name required, unique names,
allowlist entries exist in skills pool) but a full validation
overhaul is out of scope.
Race in harness sync: If two agent identity files change within the 2s debounce window, both trigger sync. The sync function is idempotent (writes final state, not incremental), so this is safe — worst case is two syncs instead of one.
What we are NOT doing
- NOT creating multiple daemon instances
- NOT creating multiple OpenClaw instances
- NOT changing Claude Code behavior (stays single-agent)
- NOT rebuilding FTS5 virtual table (post-join filter instead)
- NOT adding per-agent databases (single DB, column scoping)
- NOT adding crypto-level memory isolation (API-enforced scope is sufficient — HIPAA compliance comes from on-prem + FDE, not from memory partitioning within a trusted daemon)
- NOT overhauling config validation (out of scope, pre-existing)
Verification
bun testpasses after each phasebun run buildsucceedsbun run lintclean- Single-agent regression: existing install with no agents.roster works identically to before
signet agent add dotscaffolds ~/.agents/agents/dot/ with template SOUL.md, IDENTITY.mdsignet agent listshows roster with resolved skillssignet remember "test" --agent dotstores with agent_id=‘dot’, scope defaults to ‘global’signet remember "private note" --agent dot --privatestores with scope=‘private’signet recall "test" --agent dotreturns global + dot’s private memoriessignet recall "test"without —agent returns global only- Dashboard shows agent roster in sidebar, agent tab with cards, memory tab respects agent selector
GET /api/agentsreturns discovered agents from daemon- OpenClaw config gets agents.list entries after sync
- File changes in ~/.agents/agents/dot/ trigger sync for dot only (check daemon logs)
signet agent remove dotarchives memories, removes from roster