-
Notifications
You must be signed in to change notification settings - Fork 0
Phone Line
For Lumiverse extension developers · Last reviewed: 2026-09-17
LumiAgent reads and writes character cards, lorebooks, regex scripts, and chats out of the box. Anything inside your own extension's envelope is opaque to it unless you opt in by exposing a phone line: one rpcPool handler that answers a small set of ops.
LumiRealm is the reference implementation.
Register one rpcPool handler at <yourExtId>.phoneline. When LumiAgent dials
you, it publishes a request envelope to its own lumiagent.phoneline_request
channel, then reads your endpoint. Your handler reads the envelope back via
the requester's id and returns a response.
const ALLOWED_CALLERS = new Set(["lumiagent"]);
spindle.rpcPool.handle("lumirealm.phoneline", async (rctx) => {
if (!ALLOWED_CALLERS.has(rctx.requesterExtensionId)) throw new Error("not authorised");
const req = await spindle.rpcPool.read<PhoneLineRequest>(`${rctx.requesterExtensionId}.phoneline_request`);
switch (req.op) {
case "describe": return MANIFEST;
case "system_prompt": return { text: await buildPrompt(req.userId, req.characterId) };
case "check_write": return checkWritePath(req.extPath);
case "check_read": return checkReadPath(req.extPath);
case "list_items": return await listItems(req);
case "read_item": return { value: await readItem(req) };
case "write_field": return await writeField(req);
case "grep_items": return await grepItems(req);
default: throw new Error(`unknown op: ${(req as { op: string }).op}`);
}
});describe is the only required op. Unsupported system_prompt contributes
nothing, and unsupported check_read / check_write default to allow.
Unsupported data and mutation ops return tool errors.
The protocol uses two endpoints: yours (<yourExtId>.phoneline, dialed by
LumiAgent) and LumiAgent's (lumiagent.phoneline_request, read by your
handler). The host checks granted permissions on each read. Without an
explicit endpoint policy, the requester must have every permission granted
to the owner.
LumiAgent publishes its request channel with a narrow policy:
spindle.rpcPool.sync("phoneline_request", request, { requires: ["characters"] });LumiAgent also exposes lumiagent.phoneline_probe with
{ requires: ["characters"] }. Both extensions need characters granted
to read the request envelope or probe. Your handler endpoint still has its
own check: without a policy, LumiAgent needs your granted permissions.
Keep both the request channel and the probe narrowly scoped. Giving only
the request channel an explicit policy leaves the probe inheriting every
LumiAgent grant. Neither endpoint requires your extension to mirror
LumiAgent's mcp_servers or mcp_servers.create permissions.
An explicit requires policy must include the permissions your handler
uses, since the host also restricts the handler to that set. Keep the
caller-id check below even when the permission check passes.
| op | Required? | Purpose |
|---|---|---|
describe |
✓ | Return your SurfaceManifest. |
system_prompt |
optional | Per-character prompt fragment stitched into LumiAgent's system prompt. |
check_write |
optional | Veto direct writes to char/extensions/<yourExtId>.* paths. |
check_read |
optional | Veto direct reads of char/extensions/<yourExtId>.* paths (for opaque frozen layers). |
list_items, read_item, write_field
|
optional | Surface your data through the list_external / read_external / edit_external / update_external agent tools. |
grep_items |
optional | Regex search across every item's string leaves. Backs the grep_external agent tool. |
LumiRealm exposes additional WS-op mutation ops (asset_mutate,
attach_module, detach_module, set_toggle, set_chat_variable,
set_default_variables_text) that wrap its own frontend message handlers.
Those aren't part of the core protocol; LumiAgent tools that need them
target LumiRealm specifically.
type PhoneLineRequest = (
| { op: "describe" }
| { op: "system_prompt"; userId: string; characterId: string }
| { op: "check_write"; userId: string; characterId: string; extPath: string }
| { op: "check_read"; userId: string; characterId: string; extPath: string }
| { op: "list_items"; userId: string; surfaceId: string; characterId?: string }
| { op: "read_item"; userId: string; surfaceId: string; itemId: string; field?: string }
| { op: "write_field"; userId: string; surfaceId: string; itemId: string; field: string; value: unknown }
| { op: "grep_items"; userId: string; surfaceId: string; pattern: string; characterId?: string; ignoreCase?: boolean; fieldPrefix?: string; head?: number }
) & { callId?: string };// describe -> SurfaceManifest
// system_prompt -> { text: string | null }
// check_write -> { ok: boolean; message?: string }
// check_read -> { ok: boolean; message?: string }
// list_items -> { items: { id, label, brief? }[]; total }
// read_item -> { value: unknown; meta?: object }
// write_field -> { ok: boolean; error?: string }
// grep_items -> { hits: { itemId, itemLabel?, fieldPath, line, match, preview }[]; truncated }characterId on list_items / grep_items is present only when the
surface is per_character-scoped. Filter to items attached to that
character before returning.
const MANIFEST: SurfaceManifest = {
extension: { id: "lumirealm", name: "LumiRealm", version: "0.1.0" },
surfaces: [
{
id: "module_envelope",
label: "Risu modules",
description: "Pre-translate Risu module envelopes: triggers, lua, bg-html, regex projection, asset indexes.",
scope: "per_character", // or "global"
},
],
excludeFromSearch: [
"lumirealm.source",
"lumirealm.regex_scripts",
"lumirealm.payload.background_html",
// ...derived projections, frozen snapshots, payload mirrors of canonical fields
],
};excludeFromSearch is a flat list of path prefixes under
character.extensions.* that LumiAgent's find tools (grep, survey_cjk,
apply_glossary, audit_card_coverage) skip. Prefix matching is
segment-aware: a prefix matches itself, prefix., and prefix[. Use it for
derived caches and frozen snapshots whose canonical source lives elsewhere.
Surfacing them in find results is wasteful since check_write will refuse
the edit anyway, and the agent gets confused choosing between mirrored
paths.
extension.id is overridden by LumiAgent with the host-attested channel
namespace before use. Self-declared name and version are passed through
but treated as untrusted for identity claims (they're for your prompt logic,
not for trust UX).
The host's permission check does not identify an approved caller. Without a caller-id check, any extension that satisfies the endpoint's policy could publish a request envelope under its own prefix and read it back through your handler. Gate strictly:
const ALLOWED_CALLERS = new Set(["lumiagent"]);
if (!ALLOWED_CALLERS.has(rctx.requesterExtensionId)) throw new Error("not authorised");Don't echo the rejected id in the error string. Your whitelist is part of your security surface; probers shouldn't get confirmation of which ids you accept.
LumiAgent only dials extension ids in its KNOWN_PHONELINES whitelist. To
register, PR your { identifier, name } entry to
src/phoneline/registry.ts.
Mismatched extension.name in your describe response gets the extension
skipped with a warning.
Pairing is auto-approved when a known extension's describe succeeds.
The host enforces the endpoint's permission policy, and LumiAgent checks
the identifier and name against its whitelist. The decision is recorded at
phoneline-pairings.json in LumiAgent's storage for diagnostics and the
phone-line settings UI.
LumiAgent's bridge-status banner is driven by the actual dial outcome. On
every fresh discoverProviders (on startup, on perm change, on cache
invalidation), LumiAgent dials your describe endpoint and parses the host's
permission error if present:
Shared RPC endpoint "lumirealm.phoneline" requires requester "lumiagent"
to inherit owner "lumirealm" permissions: push_notification
The parser extracts the missing side (lumiagent) and the missing perms.
The banner says "LumiAgent is missing push_notification, required for
LumiRealm communication."
When your handler reads lumiagent.phoneline_request and its permission
check fails, preserve the host's error in any wrapper:
let req: PhoneLineRequest;
try {
req = await spindle.rpcPool.read<PhoneLineRequest>(`${rctx.requesterExtensionId}.phoneline_request`);
} catch (err) {
throw new Error(`could not read pending request from ${rctx.requesterExtensionId}: ${(err as Error).message}`);
}Both LumiAgent and LumiRealm recognize all three host error forms, including when another error wraps them:
requires requester "<R>" to inherit owner "<O>" permissions: ...
requires requester "<R>" permissions: ...
requires owner "<O>" permissions: ...
The named requester or owner is the extension missing the permission. An
explicit policy checks both sides, so revoking characters from both
extensions still fails.
If you publish your own banner, drive it from an actual RPC result. Read
lumiagent.phoneline_probe to check access to LumiAgent's probe, and inspect
request-envelope failures in your inbound handler too. A successful probe
doesn't prove that LumiAgent can call your own handler.
LumiRealm uses the same permission parser for its permission-change probe and inbound request handler. It only reports failures naming LumiRealm and permissions declared by LumiRealm. Its frontend filters incoming permission lists against that same manifest before showing a banner. This prevents Agent-only grants, including MCP, from producing an impossible request to grant them to LumiRealm.
Clear stale warnings when a later result no longer reports an actionable
permission failure. Filtering a warning doesn't turn a failed RPC into a
successful call: the error still propagates. Missing declared permissions
such as characters remain visible. Enable Debug logging to inspect
other failures.
- Keep each handler call under a second. The agent's tool loop blocks until you return.
- Validate
write_fieldinput and return{ ok: false, error }for bad asks. The error string surfaces to both the user and the agent. - Mark layers you regenerate (translator outputs, frozen import snapshots)
in
excludeFromSearchAND reject writes to them incheck_write. Point the agent at the authoring layer in the rejection message. - For layers that are pure projections with no diagnostic use, also reject
reads via
check_read. The agent gets a clear redirect instead of being free to read stale bytes. -
system_promptruns per-send. Condition on the active character so cards without your data don't get an irrelevant fragment. - Manifest changes are picked up automatically on next dial; no re-pairing flow is needed (auto-approve handles it).
Repository · Issues · GPL-3.0-or-later · Found a typo? Edit this page on GitHub