Skip to content

Phone Line

AMousePad edited this page Sep 17, 2026 · 4 revisions

Can I have your number? (˶ˆ꒳ˆ˵)

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.

How it works

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.

Permissions (read this first)

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.

Operations

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.

Request and response shapes

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.

Manifest

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).

Caller authorisation

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.

Discovery and pairing

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.

Failure modes and the bridge banner

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.

Good neighbour notes

  • Keep each handler call under a second. The agent's tool loop blocks until you return.
  • Validate write_field input 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 excludeFromSearch AND reject writes to them in check_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_prompt runs 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).

See also

Home · Workshop · Tools and Capabilities

LumiAgent Wiki

Things you can do with me

For developers

External

Clone this wiki locally