A plugin is a directory underDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/org-quicko/silo/llms.txt
Use this file to discover all available pages before exploring further.
<data dir>/plugins/ whose name appears in silo.toml. Silo transpiles TypeScript itself, so no build step is required. The virtual module silo:api is injected into the plugin’s import graph before it loads — there is no file on disk and nothing to install from npm. That is why a plugin declares no dependencies and why there is only ever one copy of ValidationError in play instead of one per plugin.
Scaffold a new plugin from a working template with:
create-silo-plugin asks what the plugin is for, then writes the manifest, a runnable stub per hook you pick, the silo:api type declarations, and the [[plugins]] block to paste into silo.toml.
package.json manifest
The manifest lives in the"silo" key of package.json. It is static on purpose: silo plugin info must show an operator what a package wants before any of its code runs.
Manifest field reference
| Key | Meaning |
|---|---|
silo | Version range of Silo this plugin supports, checked at startup. A breaking change to a hook payload is a major version of Silo. |
contributes.hooks | Which lifecycle hooks to receive. A hook the module exports but does not declare here is never called. |
contributes.routes | HTTP routes served under /api/ext/<name>/, each with method, path, optional auth, and optional body. Declaring any route automatically adds the http:route claim to the grant request. The body field sets kind ("text" or "bytes") and max_bytes, defaulting to text at 1 MiB. |
contributes.ui | Admin panel HTML file: { "entry": "./panel.html", "title": "..." }. One inlined HTML file. |
contributes.runtime | true when the module exports activate(ctx) and deactivate(ctx). Declaring it without exporting them refuses the start. |
contributes.providers | Storage or blob-storage port implementations, each { "port", "driver", "entry" }. The entry field is required because Silo imports a provider before storage exists; the provider module cannot share the worker module the rest of the plugin runs from. |
permissions.required | Claims the plugin does not work without. This is what a default grant approves. Each entry is { "claim", "reason" } — the reason field is not optional; it is what an operator reads while deciding. |
permissions.optional | Extra claims. Ungranted is a normal outcome, never an error. Each entry is { "claim", "reason" }. |
config | A JSON Schema for [plugins.config] settings in silo.toml, validated at startup. |
A package must contribute something. A plugin that contributes nothing is refused at startup. Silo automatically adds a
hooks: claim per declared hook and http:route for declared routes to the grant request — you do not need to list them again in permissions.index.ts entry point
The entry module usesdefineSiloPlugin to declare hook handlers, route handlers, and lifecycle callbacks in one object. Key names match hook names and "METHOD /path" route patterns.
ctx API
ctx is how a plugin acts on the world. Every call through ctx is an HTTP request against Silo’s own API, using the same routes, guards, and responses a key with those claims would receive. The claim check is not an analogy — a plugin is an API key with code attached.
ctx.entries.list()
Typed client for the most common operations.
ctx.entries.list(scope, collection, query) returns a paginated result.ctx.fetch()
Raw HTTP for everything else. Paths must be under
/api/. A refusal comes back as a status, not a throw.ctx.config
The plugin’s own
[plugins.config] values from silo.toml, validated against the manifest’s config schema.Route handler: request object
Every route handler receives(request, ctx). The request object carries:
| Property | Type | Notes |
|---|---|---|
request.method | string | The HTTP verb. |
request.path | string | The declared path pattern. |
request.params | Record<string, string> | Bound :name parameters. |
request.query | URLSearchParams | Query string. |
request.headers | Headers | Request headers. Authorization, X-Api-Key, and Cookie are withheld. |
request.body | string | null | Text body (default). null when the route declares body: { kind: "bytes" }. |
request.bytes | Uint8Array | null | Binary body for routes declaring body: { kind: "bytes" }. null on text routes. |
request.caller | { id, label, claims } | null | Who is calling — never the raw credential. null on a public route reached with no credential. |
A route runs with the plugin’s authority, not the caller’s. This is what a plugin route is for, since a handler bounded by the caller’s claims could only do what the caller could have done directly. Check
request.caller.claims when a route should be narrower than the plugin’s grant.Route handler: return values
A handler returns a value, never aResponse object. Silo maps the return to an HTTP response:
| Return value | HTTP response |
|---|---|
| Plain object | 200 with application/json body |
| String | 200 with text/plain body |
{ json } | 200 with the value serialised as JSON |
{ status, headers, body } | Explicit status, headers, and body |
Nothing (undefined) | 204 No Content |
throw ValidationError(msg) | 400 Bad Request |
throw ForbiddenError(msg) | 403 Forbidden |
Silo adds
X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox to every route response, replacing your own value for either header. A route answers data, not a web page — use contributes.ui to ship a browser-facing panel.Route body declarations
The default body kind is text, capped at 1 MiB. A route that receives a file declaresbytes instead:
- The cap is yours to declare, bounded by Silo at 64 MiB.
- Silo refuses a request past the cap rather than truncating it. A plugin cannot tell a body it was not given from one that was never sent, so the alternative is a
200describing work done on the wrong input. max_bytesis visible to operators when they approvehttp:route.
Admin panel (contributes.ui)
A plugin can ship one inlined HTML file as an admin panel. It appears under Settings > Plugins > your plugin, below the grant.<iframe> with sandbox="allow-scripts" and no allow-same-origin. It has no origin of its own: localStorage throws, document.cookie is empty, and nothing it fetches carries a credential automatically.
The admin injects window.silo to bridge that gap:
silo.json and silo.fetch reach your plugin’s routes only, with the operator’s key attached by the admin. None of your routes needs to be auth: "public" for a panel to work.
The admin’s theme is available as CSS custom properties: var(--text), var(--accent), and others follow whatever the operator has configured.
silo.toml plugin block
nameresolves under<data dir>/plugins/, as either a plain directory or anode_modules/<name>layout.claimsis a declarative grant. Effective authority is the union of this and anyPUT /api/plugins/{name}/grantapproval, each bounded by what the manifest requested.on_errorgoverns what happens when a non-ValidationError/ForbiddenErrorthrow escapes a hook.failrefuses the write;skiplogs the error and carries on.- The array order of
[[plugins]]blocks is hook dispatch order. There is no priority number.
Trust model
A Worker bounds faults, not malice
A plugin that crashes, spins forever, or consumes memory is timed out, torn down, and reported. The server keeps serving. Silo does not restart the plugin automatically.
A plugin is an API key with code attached
Every
ctx call goes through the same auth middleware any external key hits. The plugin never receives the database or the service layer directly.Forbidden grant targets
A plugin may never be granted
root, plugins:*, or keys:create | keys:revoke | keys:import. These would let a plugin widen its own grant.silo add validation
silo add validates a package thoroughly before writing a single file. A bad package leaves nothing behind.
- Manifest is valid — the
silokey parses and all required fields are present. - Version range — the
silofield in the manifest includes the running binary’s version. A plugin that excludes this version is refused. - Reserved driver names — a
contributes.providersentry may not use a built-in driver name (sqlite,fs,s3). The reserved names are refused before anything is written. - Archive safety — archives are inspected in full before extraction. Refused if they contain: absolute paths,
..path components, symlinks, hard links, device nodes, or setuid/setgid/sticky mode bits. An ordinary executable at0755is fine; the check is about privilege, not the executable bit.
silo add unpacks the package into <data dir>/plugins/<name>/ and appends a [[plugins]] block to silo.toml. It runs none of the package’s code and no lifecycle script, ever.
Upgrading plugins
An upgrade never escalates automatically. When a new version of a plugin requests claims the current grant does not cover, the plugin’s record moves toneeds_review and the plugin keeps running on the grant it had. The new claims are simply not in it.
Silo does not advance the approved digest while a review is outstanding. A second install would settle the review in silence, so silo add --force with a version that changes the claim set requires an explicit re-grant:
silo plugin list and silo plugin doctor both flag the plugin as needs_review. Its hooks and routes continue to run with the previously approved claims.