Agents and humans on the same chunks. How v1.5.0 made co-authoring the launch plan possible.
I'd been hand-editing a flat Markdown launch plan for three weeks. Every refactor cost 30 minutes of cut-and-paste: moving status markers between sections when something published, rewriting the at-a-glance table when the schedule shifted by a day, tracking which Mastodon posts had drafts and which were "draft pending." Over the launch month I lost hours to clerical work that the file shape itself was the reason for.
Two weekends ago I imported the plan as a chunked-graph Document. Forty-seven chunks. Headings became hierarchy. Each social post became a structured entry with typed fields: platform, date, status, blog_slug. The Web UI showed the same tree the MCP plugin saw. The publishing plan stopped being a file and started being data.
Then I hit a wall that this post exists because of.
The MCP plugin authenticated as mcp-operator (a synthetic operator identity the runtime defaulted to). The Web UI logged me in as a different principal (the one I'd been using for everything else). SQL Memory's per-scope file isolation meant my MCP-created Document landed in data/sqlmem/default/user/mcp-operator.db while the Web UI's scope: user view read from denn.db. Two distinct SQLite files. The Document was provably created (I could query it from MCP) and provably invisible (no entry in the Web UI tree).
This was the gap that human-and-agent co-authoring couldn't cross. Both sides could edit Documents. They couldn't edit the same Documents. Today's v1.5.0 closes that gap.
v1.5.0 ships three changes that compose into a workflow we couldn't have before. RFC AG keys the /v1/_mcp dispatch off the authenticated principal, so user-scoped Memory, Documents, and Path under the MCP-server transport now share the same per-scope SQLite file as the off-run HTTP path. RFC AO adds config-declared static (tenant, subject) principals: a top-level principals: block in loomcycle.yaml declares a stable service identity, the bearer secret lives in .env.local via token_env, and one declared bearer authenticates both the Web UI login and an MCP thin client at the same (tenant, subject). RFC AN makes --config repeatable with deep-merge so configuration bundles stack onto local config without copy-paste. The MCP-server transport route also opens from substrate:admin to substrate:tenant, so a tenant token now drives a fully tenant-confined MCP session. Additive on the substrate layer; existing deployments without a principals: block keep working unchanged. No new adapter surface, so TS and Python adapters stay at 1.4.0; the Claude Code plugin bumps to v1.5.0.
The rest of this post: the per-principal dispatch fix in /v1/_mcp, the declared-principals shape, the one-bearer alignment, the concrete launch-plan workflow this unlocked, and what the next iteration looks like.
RFC AG: the MCP server transport now keys off the principal
The cross-transport identity gap had a specific shape. The off-run HTTP path (POST /v1/_document) resolved tenant and user from the authenticated principal stamped on the request context. The MCP-server transport (/v1/_mcp) didn't. It ran every dispatch as a global operator regardless of the bearer, which is why the route gate was substrate:admin-only. A tenant token had no way in.
v1.5.0 keys the dispatch off the authenticated principal. The piece that does it is a small wrapper around the existing run-identity context:
// internal/api/http/mcp_principal.go (sketch)
func mcpPrincipalCtx(parent context.Context, p *Principal) context.Context {
return tools.WithRunIdentity(parent, tools.RunIdentity{
TenantID: p.TenantID,
UserID: p.Subject,
Scopes: p.Scopes,
})
}
Every builtin-tool dispatch in /v1/_mcp now passes through that wrapper. So a Document op=create_document scope:"user" from an MCP session keys on p.Subject the same way the off-run HTTP path keys on the same subject. The MCP-created Document lands in the same per-scope SQLite file the Web UI reads. The mismatch goes away by construction, not by convention.
What changed at the route gate
The /v1/_mcp route moves from substrate:admin to substrate:tenant. A tenant token may now open a session. The per-tool gate inside the session still withholds admin-only meta-tools (token minting, runtime admin, snapshot capture/restore, cross-scope channel listing) by hiding them from tools/list and refusing them on tools/call. The tools a tenant can call (def authoring, run lifecycle, memory, channel, path, document, hooks) are tenant-confined exactly like their HTTP twins.
Two changes around spawn_run / spawn_runs compose with this. A new applyPrincipal step overrides the wire-supplied tenant / user with the authenticated principal's values, so an agent-spawned run from MCP inherits the parent principal's identity rather than carrying whatever the client wrote into the request. And the hook meta-tools (already tenant-isolated since RFC AF) promote to tenant-confinable on the MCP transport: a tenant can author and inspect its own hooks without admin rights.
substrate:admin still satisfies the route, so existing admin sessions work unchanged.
RFC AO: declared principals, one bearer for both surfaces
Per-principal dispatch is half the fix. The other half is making sure the Web UI and the MCP plugin authenticate as the same principal. RFC AO does that with a new top-level principals: block in the yaml.
principals:
denn: # informational handle (the map key)
tenant: "" # authoritative tenant ("" = shared/default)
subject: denn # authoritative user id
scopes: [substrate:admin] # admin is EXPLICIT, declared principals aren't admin by default
token_env: LOOMCYCLE_TOKEN_DENN # env var holding the SECRET (lives in .env.local)
The yaml carries only token_env, never the secret itself. The bearer value lives in .env.local (e.g. LOOMCYCLE_TOKEN_DENN=lct_…). The token_env name must be LOOMCYCLE_*-prefixed (or an allowlisted third-party name) and may not name one of loomcycle's own infra secrets (the DSN, the operator-token pepper, LOOMCYCLE_AUTH_TOKEN, the upstream MCP token).
The bearer resolver tries OperatorTokenDef minted tokens first, then declared principals, then the legacy single-bearer LOOMCYCLE_AUTH_TOKEN. Comparison is constant-time. A token value shared by two declared principals is a config-load error. An empty token_env at boot makes that principal inert and logs a startup warning (not a silent open door).
The one-bearer alignment
Use one declared token for both /ui/login and the MCP thin client's LOOMCYCLE_MCP_UPSTREAM_TOKEN. Both resolve to the same (tenant, subject). Combined with the RFC AG per-principal dispatch, an MCP agent's user-scoped Documents and Memory land under exactly the same user the UI reads. No synthetic-operator mismatch.
The runbook I executed for the launch plan, end to end:
# 1. Declare the principal in loomcycle.yaml
principals:
denn:
tenant: ""
subject: denn
scopes: [substrate:admin]
token_env: LOOMCYCLE_TOKEN_DENN
# 2. Generate a bearer and put it in .env.local
$ openssl rand -hex 32 | tr -d '\n' > /tmp/bearer
$ cat >> ~/.config/loomcycle/auth.env << 'EOF'
LOOMCYCLE_TOKEN_DENN=<the-bearer>
LOOMCYCLE_MCP_UPSTREAM_TOKEN=<the-same-bearer>
EOF
# 3. Restart loomcycle so it loads the new principals: block
$ ./loomcycle.sh restart
# 4. Update the Claude Code plugin's auth_token to the same bearer
$ /plugin # loomcycle → user config → auth_token
# 5. Log into the Web UI with the same bearer
$ open http://localhost:8787/ui/login
The verification: from the MCP thin client, Context op=self returned user_id: denn. The Web UI's /ui/paths?scope=user listed the same Documents the MCP session created. Both surfaces resolved to the same principal by construction; the cross-transport file boundary disappeared.
RFC AN: config layering for bundles
The third change is a small ergonomics fix that pays off across this release. --config is now repeatable; the runtime accepts multiple files and deep-merges them left to right. LOOMCYCLE_CONFIG_FILES takes the same list as a colon-separated env var for containers.
One recursive rule. Mapping ⊕ mapping merges keys (a same-named entry field-merges, matching the LOOMCYCLE_AGENTS_ROOT precedent). Scalar or sequence: the later layer replaces the earlier. Every replaced leaf is logged at startup. LOOMCYCLE_CONFIG_STRICT=1 makes a cross-layer conflict fatal at load time.
Each file keeps its own ${ENV} expansion before the merge runs, and the merged whole runs the existing validate() pass, so a layered config can't degrade the validation floor. A single --config remains byte-identical to before. config render and an in-YAML include: directive are deferred follow-ups.
Why this matters for the workflow below: bundles. A drafter agent's AgentDef + skill + system prompt can ship as bundles/social-drafter/. The operator stacks the bundle on top of their local config without copy-paste, and the bundle's defs merge with the operator's existing principals + providers + run config.
What the alignment unlocks: the launch plan as a workflow
Once both surfaces resolve to the same (tenant, subject), the launch plan stops being a file and becomes a workflow. Concrete walk-through, using the actual Document at /docs/marketing/loomcycle on my local runtime.
Step one: scaffold. I created the Document by importing the old Markdown via Document op=import_md. The runtime parsed headings into a chunk tree and returned the document_id plus 47 chunks. Then I added 18 publication chunks for this week and next: 11 Mastodon+Bluesky day chunks, 6 X thread chunks, 1 LinkedIn post chunk. Each was typed:
// publication chunk type, defined once with Document op=define_type
fields:
- { name: platform, type: enum, values: [mastodon+bluesky, x, linkedin] }
- { name: date, type: date }
- { name: status, type: enum, values: [scheduled, drafted, posted, done, skipped] }
- { name: blog_slug, type: string }
- { name: day_number, type: int }
- { name: t_offset, type: string }
The two Monday entries (M+B Day 7 + X Thread 6, both agent-ensembles-arrive) were marked status: done since they'd already published. The other sixteen chunks landed as status: scheduled. Each chunk's body carried the post template, the blog URL, the angle, and the suggested hashtags. The locked X thread drafts (4-5 tweets each) went in verbatim.
Step two: draft. From the MCP plugin I called Document op=update_chunk on the M+B Day 8 chunk:
{
"tool": "document",
"input": {
"op": "update_chunk",
"scope": "user",
"id": "d24c59d45b63768f838f06ab908c96a1",
"revision": 1,
"status": "drafted",
"fields": { "status": "drafted" },
"body": "<the Mastodon + Bluesky drafts, 461 + 286 chars, both
under their platform ceilings, voice rules clean>"
}
}
The chunk returned revision: 2. The Web UI showed the change live (it subscribed to the documents/<id>/chunks Channel topic and re-rendered on the broadcast event). Optimistic concurrency meant if I'd also been editing that exact chunk in the UI when the MCP call landed, the runtime would have caught the conflict at the revision check. Different chunks: no contention, no merge.
Step three: query. Asking "what's still to draft this week?" used to be a manual grep. Now it's a SQL escape hatch through SQL Memory's validator-gated read-only query:
{
"tool": "document",
"input": {
"op": "query_chunks",
"scope": "user",
"sql": "SELECT id, title, fields->>'platform', fields->>'date'
FROM chunks
WHERE type = 'publication'
AND status = 'scheduled'
AND fields->>'date' <= '2026-06-30'
ORDER BY fields->>'date'"
}
}
The Web UI runs the same shape for a kanban view (status columns). An agent runs the same query to pick its next work item. The status is data.
The natural shape of the agentic workflow
With the substrate aligned, the workflow becomes:
- Scaffold (human). I create
status: scheduledchunks for the week's slots. One per platform-day. - Drafter agent. Subscribes to
documents/<id>/chunks. Picks the nextscheduledchunk withdatewithin 72 hours. Fetches the linked blog post. Writes Mastodon + Bluesky drafts (or the X thread / LinkedIn post body) inside the chunk body. Flipsstatustodrafted. Channel event fires. - Review (human). I open the Web UI, read the draft in the chunk editor, edit copy where needed, flip
statustoapproved(or acceptdrafted). - Posting (human, manually for now). I post to Mastodon / Bluesky / X / LinkedIn from the platform's native composer. Flip
statustopostedwith a timestamp field. - Reporter agent. Subscribes to the same channel topic. Watches for the
scheduled → drafted → approved → postedtransitions. Aggregates a weekly digest chunk under a new§6 Weekly digestheading with what shipped, what got engagement, what's queued.
Three behaviors that the old flat-Markdown plan couldn't support:
- Optimistic concurrency on the chunk. Agent and human edit different chunks concurrently. The revision field catches the rare same-chunk collision (the editor side gets the conflict and re-reads before retrying). No git merge on a single file.
- Status as a queryable field, not prose.
SELECT … WHERE status='scheduled'is something an agent can do. The kanban view is the same query with a column-grouped render. The plan file used to encode status as ✓ marks scattered through prose: searchable by eye, not by tool. - Per-chunk audit + Channel events. Every
update_chunkis a row in SQL Memory's audit log and a Channel event ondocuments/<id>/chunks. Agents subscribe to the topic and react to state changes. The publishing plan becomes a workflow, not a document.
The trust posture, plainly
A declared principal is an authority grant, not a name. The Path tree (RFC AL) treats dirents as names; this is the opposite layer. The yaml's scopes: list determines what the bearer can do. substrate:admin is explicit and never a default. The Web UI session and the MCP thin client share the same identity by both presenting the same bearer; neither widens the other's authority.
The per-tool gate inside /v1/_mcp matters for the multi-tenant story. A substrate:tenant bearer drives a session that can author its own AgentDefs, SkillDefs, MCPServerDefs, run lifecycle, memory, channels, paths, documents, and hooks. It cannot mint tokens, cannot capture or restore snapshots, cannot reach another tenant's data, and cannot pause the runtime. The hidden-from-tools-list discipline is hard: the tools don't appear, and a direct call refuses. substrate:admin sessions see the full meta-tool set.
Trigger-spawned runs (scheduler, webhook) inherit their tenant from the static def, not from an inbound bearer (there isn't one). The webhook def carries the tenant_id, and for webhooks specifically that value comes only from the static def, never from the attacker-influenceable payload_mapping. The boundary is unchanged.
Compatibility
Additive on the substrate. RFC AG is an authentication and routing change on an existing endpoint (the route moves substrate:admin → substrate:tenant, the in-session per-tool gate preserves the admin-only boundary). RFC AO is config-only (a new top-level block in the yaml). RFC AN is config-only (multiple files, deep merge). The Web UI changes are the embedded SPA.
The TS (@loomcycle/client) and Python (loomcycle) adapters are unchanged since v1.4.0. No new wire surface required adapter additions, so v1.5.0 intentionally ships no @loomcycle/[email protected] and no python-v1.5.0; their publish jobs skip-clean. Existing adapter code keeps working unchanged against v1.5.0.
The Claude Code plugin is v1.5.0. A tenant or config-declared auth_token now drives the plugin, with the admin-only commands (/loomcycle:operator-token, /loomcycle:snapshot) flagged in the plugin docs and refused at the route when the bearer lacks substrate:admin.
Existing deployments without a principals: block keep working unchanged. The legacy LOOMCYCLE_AUTH_TOKEN still authenticates as the default identity through the resolution-order fallback.
How to enable it
# 1. Declare the principal in loomcycle.yaml
principals:
denn:
tenant: ""
subject: denn
scopes: [substrate:admin]
token_env: LOOMCYCLE_TOKEN_DENN
# 2. Put the bearer secret in .env.local (never the yaml)
LOOMCYCLE_TOKEN_DENN=<a strong random bearer>
LOOMCYCLE_MCP_UPSTREAM_TOKEN=<same bearer>
# 3. Restart loomcycle so it loads the principals: block
# 4. Update your MCP client's bearer (Claude Code plugin user config,
# your own client, etc.) to the same value
# 5. Log into the Web UI with the bearer at /ui/login
# 6. Verify: from the MCP client, call Context op=self.
# user_id should be the principal's subject, not "mcp-operator".
Full release notes in REVISIONS.md. Operator topics: docs/MCP_SERVER.md (the per-principal MCP transport) and docs/CONFIGURATION.md (declared principals + §9e config layering).
Companion reading: v1.4.0, Path + Document primitives (the substrate this release composes with), v1.3.0, Bashbox, v1.2.0, SQL Memory.