Skip to content

Authoring a Plexus extension ​

You are authoring an extension for a local Plexus instance. An extension is a runtime-registered connector: a manifest that declares a source and the capability entries it contributes. Installing it makes those capabilities discoverable — it does NOT grant access. A human still approves every install and issues every grant.

This page is the contract the authoring agent follows. The full spec is the extension spec.

1. Manifest shape ​

An extension manifest declares capabilities; the gateway materializes them into a source and projects each into every agent's handshake manifest, scoped to that agent's authorized subset

jsonc
{
  "manifest": "plexus-extension/0.1",
  "source": "my-tool",            // SourceId; seeds every entry id (<source>.<name>)
  "label": "My tool",
  "transport": "local-rest",      // default transport for caps that don't override
  "capabilities": [ /* ExtensionCapabilityDecl[] */ ],
  "secrets": [ /* ExtensionSecretRef[]  (optional) */ ],
  "serviceHint": { /* how to locate a local service (optional) */ }
}

Each ExtensionCapabilityDecl:

jsonc
{
  "name": "vault.write",          // <noun>.<verb>; full id = <source>.<name>
  "kind": "capability",           // capability | skill | workflow
  "label": "Write a vault note",
  "describe": "Write/overwrite a note at {path}. Use when the user asks to save…",
  "io": { "input": { "type": "object", "properties": { "path": {"type":"string"} } } },
  "grants": ["write"],            // verbs this cap requires: read | write | execute
  "transport": "local-rest",      // cli | local-rest | skill | workflow | stdio | ipc (no mcp)
  "route": { /* transport routing — see §3 */ }
}

A good describe is the agent-relevance signal — say WHAT it does, WHEN to use it, and name the inputs. Be specific; vague describes make the capability undiscoverable.

The id is <source>.<name> — do NOT repeat the source in name

The full capability id is built by prefixing source automatically. For source: "user-profile", name: "read" yields the id user-profile.read, while name: "user-profile.read" yields the doubled user-profile.user-profile.read — and it still validates, so the mistake is silent. Pick name as the unprefixed part: <noun>.<verb> when the source groups several nouns (vault.read, vault.write), or a bare <verb> for a single-purpose source (read).

2. EntryKinds ​

  • capability — a callable backed by a transport (cli / local-rest / ipc / stdio).
  • skill — pure markdown usage guidance, no transport. body: { format:"markdown", markdown }.
  • workflow — composes existing entries via members[] (each must resolve once registered).

3. Per-transport route requirements ​

route is read ONLY by the owning transport, never by core. Per transport:

cli (the #2 RCE surface) ​

jsonc
"route": {
  "bin": "ls",                    // bare binary name — NO path, NO shell metacharacters
  "args": ["{dir}"],              // argv template; {placeholders} fill from io.input
  "allowedBins": ["ls"]           // user-confirmed allow-list (part of the approval surface)
}

local-rest (the #3 SSRF / secret-redirect surface) ​

jsonc
"route": {
  "baseUrl": "http://127.0.0.1:27123",  // loopback by default; a non-loopback host is opt-in and
                                        // requires an explicit, user-confirmed `allowedHosts` entry
                                        // (the approval surface) — see `transport-policy.ts`
  "allowedHosts": ["127.0.0.1:27123"],  // host allow-list (part of the approval surface)
  "method": "PUT",
  "pathTemplate": "/vault/{path}",      // canonical URL path key (`path` is a legacy alias)
  "secret": { "name": "vault-key", "attach": "bearer" }  // references secrets[] by name
}

The secret VALUE never appears in the manifest — it lives under ~/.plexus/secrets/<name> and the transport attaches it at dispatch.

skill / workflow ​

  • skill: no route; supply body.
  • workflow: no route; supply members[] referencing present entry ids. Cross-source attach (a skill/workflow reaching into a different source) is OFF by default — it is a prompt-injection channel and must be explicitly gated + human-confirmed.

4. Security surface (what the human approves) ​

When you install, the human sees exactly: the cli bins the extension may spawn, the non-loopback rest hosts it may reach, any cross-source skill attaches, the verbs each capability requires, and whether it is transport-backed. Keep the surface minimal — request only the bins/hosts/verbs you actually need.

5. Install flow ​

  1. Fetch this guide: GET /admin/api/extensions/authoring-guide.
  2. Draft the manifest as JSON.
  3. Preview (no commit): POST /admin/api/extensions/preview with { manifest }. Read valid / reasons[]; if valid:false, fix the manifest and re-preview. Show the human the returned surface (cli bins / rest hosts / cross-source / verbs).
  4. Install (human approves): POST /admin/api/extensions with { manifest }. The local user is the connection-key holder = the human approver, so this commits directly and audits source.install. Response: { ok, source, registered, revision, reason? }.
  5. Remove: DELETE /admin/api/extensions/:source.

Installed extensions persist across a gateway restart

An admin-installed extension is persisted to ~/.plexus/extensions.json and replayed on boot, so its capabilities come back after a restart without a re-install. DELETE/remove drops it from that durable store too.

CLI equivalents: plexus extension preview|add|list|remove.

6. Worked example — a local-rest "vault write" extension ​

jsonc
{
  "manifest": "plexus-extension/0.1",
  "source": "my-vault",
  "label": "My local vault",
  "transport": "local-rest",
  "secrets": [{ "name": "my-vault-key", "attach": "bearer" }],
  "capabilities": [
    {
      "name": "notes.read",
      "kind": "capability",
      "label": "Read a note",
      "describe": "Read the markdown of a note at {path}. Use to fetch existing note content.",
      "io": { "input": { "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] } },
      "grants": ["read"],
      "transport": "local-rest",
      "route": {
        "baseUrl": "http://127.0.0.1:27123",
        "allowedHosts": ["127.0.0.1:27123"],
        "method": "GET",
        "pathTemplate": "/vault/{path}",
        "secret": { "name": "my-vault-key", "attach": "bearer" }
      }
    },
    {
      "name": "notes.write",
      "kind": "capability",
      "label": "Write a note",
      "describe": "Create or overwrite the note at {path} with {content}. Use when saving content the user dictated.",
      "io": { "input": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } },
      "grants": ["write"],
      "transport": "local-rest",
      "route": {
        "baseUrl": "http://127.0.0.1:27123",
        "allowedHosts": ["127.0.0.1:27123"],
        "method": "PUT",
        "pathTemplate": "/vault/{path}",
        "body": "{content}",
        "secret": { "name": "my-vault-key", "attach": "bearer" }
      }
    },
    {
      "name": "notes.howto",
      "kind": "skill",
      "label": "How to use my-vault",
      "describe": "Usage guidance for my-vault.notes.read / notes.write.",
      "grants": [],
      "transport": "skill",
      "body": { "format": "markdown", "markdown": "# my-vault\nRead with `notes.read { path }`; write with `notes.write { path, content }`. Paths are relative to the vault root." }
    }
  ]
}

This extension is transport-backed (local-rest) and write-capable, so its approval surface lists restHosts: ["127.0.0.1:27123"] and the write verb on my-vault.notes.write — exactly what the human signs off on.

7. Best practices & self-check ​

A manifest that validates is not yet a good citizen. These practices make your extension trustworthy to the humans who approve it and useful to the agents who discover it.

7a. Implement the health check ​

A source SHOULD implement the per-source health protocol so the live availability of its capabilities is surfaced — both in the admin dashboard and to agents that discover it:

ts
health(): Promise<{ status: "ok" | "degraded" | "unavailable" | "unknown", detail?: string }>
  • ok — reachable and serving. degraded — up but impaired. unavailable — down/unreachable.
  • It is optional: a no-op is allowed and just reports unknown. But implementing it lets agents route around an unavailable source instead of failing an invoke blind.
  • If health() is absent, status is derived from checkRequirements() (e.g. missing binary / unreachable host) — and if that says nothing, it falls back to "unknown".

Health is reconciled with the source_unavailable invoke error (§7b): a source that reports unavailable should also fail invokes with source_unavailable, so discovery and dispatch agree.

7b. Return precise, semantic errors ​

When a capability fails, feed the calling agent a standard Plexus error code plus a clear, human-readable message/detail — never an opaque 500 or a vague string. A precise error lets the agent recover (retry, pick another source) or tell the user exactly what's wrong.

Use the standard codes: source_unavailable, transport_error, schema_validation_failed, grant_required (and the others in the spec).

jsonc
// BAD — opaque, unactionable:
{ "error": "failed" }

// GOOD — semantic code + a message the agent (or user) can act on:
{ "code": "source_unavailable",
  "message": "Obsidian REST API not reachable at 127.0.0.1:27124 — is the plugin running?" }

7c. Self-check checklist (run before installing) ​

Before you POST /admin/api/extensions, tick each of these off:

  • [ ] Manifest validates — run plexus extension preview <manifest.json> and confirm valid:true. Review the printed security surface (declared cli bins / rest hosts).
  • [ ] Transports are reachable & host-confined — loopback (127.0.0.1/localhost) is allowed by default; a non-loopback host is opt-in and requires an explicit, user-confirmed allowedHosts entry (the approval surface) — see transport-policy.ts. The local service is actually up.
  • [ ] Secrets referenced by name only — no secret values anywhere in the manifest.
  • [ ] Capabilities are honest — each has a specific describe (what/when/inputs) and an accurate io schema; you're not over-claiming what a cap does.
  • [ ] Health implemented (or deliberately skipped) — skipping health() is fine, but make it a choice, not an oversight (§7a).
  • [ ] Errors are semantic — failures return a standard code + readable message, not a 500 or {error:"failed"} (§7b).

8. Conformance checklist ​

  • [ ] manifest is "plexus-extension/0.1"; source is a non-reserved id.
  • [ ] every cap has name (<noun>.<verb>), kind, label, a specific describe, grants, transport.
  • [ ] cli caps: bare bin + args + allowedBins. local-rest caps: loopback baseUrl + allowedHosts + secret ref.
  • [ ] secrets referenced by NAME only (no values in the manifest).
  • [ ] workflows reference present member ids; cross-source attach only if explicitly intended.
  • [ ] previewed (valid:true) before install; minimal cli-bins / rest-hosts / verbs surface.