The connector asked for a sudo password on every privileged call and failed whenever nobody answered it, which is every unattended run. - Always probe `sudo -n` first, even for aliases set to sudo="prompt". `_sudo_prefix` used to elicit unconditionally, so a host granting this user NOPASSWD still opened an Agent Inbox prompt; with no human there it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and surfaced as "sudo password required (user declined or timed out)". sudo refuses before running anything when it wants a password, so the probe is side-effect free. - Strip a leading `sudo` from `command` and turn it into sudo=true. Agents write `exec(command="sudo systemctl restart x")`: with sudo=false that ran a tty-less sudo, with sudo=true it nested `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone. sudo_user now implies sudo=true. - Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and redirections are elevated too instead of only the first word. - Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It is consulted only after `sudo -n` proved a password is needed, so on a NOPASSWD host it never lands in the command's own stdin. - Actionable errors for every sudo failure mode, and a `hint` on a nested sudo we could not peel off. General review of the same server: - Drain stdout and stderr together and make timeout_sec a real wall-clock deadline. Both streams share one SSH channel window, so reading stdout to EOF first stalled once a chatty stderr filled it. Command stdin is now closed after the optional password. - Queue messages that arrive while awaiting an elicitation reply instead of discarding them, so a concurrent tools/call is not lost. - Record the client's `elicitation` capability at initialize and fail fast when it is absent rather than blocking on a prompt nobody can answer. - Tolerate null/string integer arguments (depth, max_results, context_lines, timeout_sec). - Realign the version across both manifests: fragment.json said 5/1.0.4 while connector.json said 2/1.0.1, so the feed was permanently ahead of the installed version and offered an update forever. Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the single source of truth, docs/connector.manifest_guide.md retired to a pointer, CLAUDE.md audited against the repo, compile.py docstring fixed, and an opencode.json config.
26 KiB
Skald Connector Authoring Guide
This file is the single source of truth for the connector format. It lives in the
marketplace repo (connectors.skaldagent.net) and is referenced — not copied — by the
Skald application repo (~/projects/skald-circle). Change the format here, and here only.
Give this file to any agent that produces new connectors.
A connector is a folder served by the marketplace. Skald installs it, verifies every file against a SHA-256 pinned in the index, then either runs it on the host (global connector) or copies it into the user's container and runs it there (per-user connector, blueprint §6/§7).
Everything below marked "the client enforces" was checked against the implementation in
skald-circle (src/frontend/api/marketplace.rs, src/frontend/api/mcp.rs,
crates/skald-core/src/mcp/{mod,install,verify,oauth}.rs).
0. Folder layout
One folder per connector, named exactly like its id, flat:
connectors/myconn/
├── fragment.json ← index entry (compiler input, never served as-is)
├── connector.json ← the manifest Skald reads
├── server.py ← the entry file (or index.js…)
├── requirements.txt ← or package.json
├── verify.py ← optional
├── icon_sm.svg
└── icon_lg.svg
Keep the folder flat. The client accepts a relative sub-path (pkg/server.py) but
scripts/compile.py only scans the folder's top level: files in a subdirectory are
silently left out of files[], never downloaded, and the connector breaks at runtime with
no error anywhere. If a tree is genuinely needed, teach the compiler to recurse first.
1. The three source documents (+ one compiler)
You maintain three files by hand. A fourth, connectors.json, is generated.
1a. connectors/index.json — the order
A flat JSON array of folder ids, in display order:
["gmail", "gcal", "myconn"]
This is the compiler's input list. A connector missing from it does not exist.
1b. connectors/<id>/fragment.json — the index entry
The connector's entry in the compiled index, with every field except files[] (the
compiler adds that one).
{
"id": "whatsapp", // unique slug = folder name
"name": "WhatsApp",
"version": 8, // INTEGER build number — the update key (§7)
"version_string": "2.2.0", // semver, display only
"version_release_date": "2026-08-23", // ISO date, display only
"type": "mcp_local", // mcp_local | mcp_remote (§3)
"scope": "user", // user | global (§3)
"icon_small": "whatsapp/icon_sm.png", // relative to the FEED ROOT, not the folder
"icon_large": "whatsapp/icon_lg.png",
"user_description": "Send and read WhatsApp messages from your linked account.",
"requires": ["NODE"], // API_KEY | ENV | NODE | PYTHON | OAUTH | DOCKER
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
"folder": "whatsapp", // defaults to id
"auth": { "type": "qr" }, // informational here — see the table in §1e
"tools": [ … ] // informational here — see §2a
}
Note the two icon vocabularies: icon_small / icon_large in the index are relative to
the feed root (whatsapp/icon_sm.png), while files[] and connector.json name them from
inside the folder (icon_sm.png). The client reconciles the two, but only records an icon
whose file was actually installed — so icons must be shipped in the folder, never hot-
linked.
1c. connectors/<id>/connector.json — the manifest
The richer document, fetched per connector and mapped into Skald's catalog.
{
"id": "whatsapp",
"name": "WhatsApp",
"version": 8, // must match fragment.json (§7)
"version_string": "2.2.0",
"version_release_date": "2026-08-23",
"type": "mcp_local",
"scope": "user",
"requires": ["NODE"],
"tags": ["messaging", "mcp", "local", "whatsapp", "qr"],
"auth": { "type": "qr" }, // none | api_key | oauth2 | qr | ssh_key (§4)
"launch_command": "node index.js", // human-readable; mcp_config is what runs
"transport": "stdio", // stdio | streamable-http (may also sit in mcp_config)
"mcp_config": {
"command": "node", // interpreter (local) …
"args": ["index.js"] // … args[0] MUST name the entry file
},
"dependencies": ["@whiskeysockets/baileys@7.0.0-rc.14"], // display only (§5)
"setup_instructions": ["Scan the QR code with WhatsApp → Linked devices"],
"docs": [{
"lang": "en",
"description": "Human blurb shown in the UI.",
"llm_short_description": "One line the model reads to decide whether to use this connector."
}],
"env": [], // form fields the user fills (§4b)
"verify": { "command": "python3 verify.py", "timeout_secs": 20 }, // optional (§6)
"tools": [{ "name": "send_message", "display_name": "Send Message" }], // optional (§2a)
"homepage": "https://web.whatsapp.com",
"icon_small": "icon_sm.png", // relative to the FOLDER here
"icon_large": "icon_lg.png"
}
mcp_config.args[0] is load-bearing for a local connector: it is how Skald learns which
file to run — an install fails outright without it. At activation Skald rewrites it to the
file's path inside the user's container (/root/.skald/mcp/<name>/<entry>), so keep it a
plain relative filename (index.js, server.py).
transport may sit either at the top level or inside mcp_config — the client reads
mcp_config.transport first, then the top-level one, then infers (remote → http,
otherwise stdio). Both spellings are in use in this repo; pick one per connector and keep
it consistent between the two files. streamable-http and http both normalise to HTTP;
anything unrecognised silently becomes stdio, which for a remote connector means trying to
spawn a command that does not exist.
llm_short_description is model-facing: it is the one-liner injected into the LLM's
system prompt so the model knows what this connector does. Keep it short and functional
("Weather — current conditions, 16-day forecast, and AQI data for any location"), not a
list of tools (the model discovers those after activate_tools).
1d. connectors/connectors.json — the compiled index (generated)
Never edit this file by hand. scripts/compile.py reads index.json, loads each
fragment.json, scans the folder's real files, computes their SHA-256 + size, and writes the
result:
python3 scripts/compile.py # regenerate
python3 scripts/compile.py --verify # fail if the committed index is stale
The compiler excludes fragment.json, connectors.json, index.json, compile.sh,
compile.py, update_hashes.py, .DS_Store and every subdirectory. Everything else is
hashed, including connector.json.
"files": [
{ "path": "index.js", "sha256": "…", "size": 21258 },
{ "path": "package.json", "sha256": "…", "size": 302 },
{ "path": "connector.json", "sha256": "…", "size": 620 },
{ "path": "icon_sm.png", "sha256": "…", "size": 306 }
]
files[].pathis relative to the connector folder.- Digests and sizes are computed by the compiler. Never write them by hand.
- The index is the signable root: it is the one document that names a connector's files and their digests. The client refuses any file whose bytes do not match, all-or-nothing — a mismatch leaves nothing on disk.
- Do not ship
node_modules/or vendored wheels (§5).
1e. Who reads what — the authority table
The client hydrates a card from two documents, and they are not interchangeable. Getting this wrong is the most common way a correct-looking connector misbehaves.
| Field | Read from | Notes |
|---|---|---|
id, folder, tags |
index (fragment.json) |
folder defaults to id |
name |
index, falling back to manifest | |
user_description |
index, falling back to docs[0].description |
the human blurb |
icon_small, icon_large |
index | feed-root-relative; the file must be in files[] |
files[] |
index (manifest's is a legacy fallback) | the trust root |
requires |
manifest, falling back to index | |
type, scope |
manifest, falling back to index | keep them identical |
version trio |
manifest wins, index is the fallback | a mismatch is logged as a desync warning (§7) |
auth |
manifest only | the index's auth is never parsed; only requires is used as a coarse fallback |
tools[] |
manifest only | a tools[] that lives only in fragment.json does nothing (§2a) |
env[], verify, mcp_config, docs, dependencies, setup_instructions, homepage |
manifest only |
So: auth and tools[] in fragment.json are documentation for the catalog page and for
whoever reads the index — harmless, worth keeping in sync, but the manifest is what runs.
2. Server contract (MCP over stdio)
A local connector is a program speaking JSON-RPC 2.0 over stdin/stdout. It MUST handle:
initialize→{ protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo }notifications/initialized→ no responseping→{}tools/list→{ "tools": [ { name, description, inputSchema, title? } ] }— an object, not a bare arraytools/call→{ content: [ { type: "text", text } ], isError? }
Any message without an id is a notification and must produce no output at all.
Answering one desynchronises a strict client.
stdout is reserved for JSON-RPC only. Send all logs/diagnostics to stderr. Anything a
library prints to stdout (a logger, a banner) corrupts the protocol — silence it (Baileys/pino
→ a silent logger; Python → print(…, file=sys.stderr)). Guard stdout writes with a lock if
the server has background threads.
Report tool failures as a normal result carrying isError: true with a readable message —
not as a JSON-RPC error, and not as a "successful" result with an {"status":"error"} body
the model cannot distinguish from data.
A remote connector is an HTTP MCP endpoint (mcp_config.url + transport: "streamable-http"); no code runs on the box.
2a. Friendly tool names (tools[]) — optional
Raw MCP tool names are ugly in the chat UI (search_files, send_message). Two ways to fix
that, in order of preference:
titlein yourtools/listentries (preferred for servers we control) — Skald uses it automatically, no manifest change needed.- The manifest's
tools[]block — for remote connectors and third-party packages (npx -y firecrawl-mcp) whosetools/listwe cannot edit:
"tools": [
{ "name": "send_message", "display_name": "Send Message" }
]
name— the raw tool name exactly as the server returns it fromtools/list.display_name— the friendly card title (English only; not internationalized).
Resolution order: tools[].display_name → the MCP title field → a prettified raw name
(send_message → "Send Message").
⚠️ tools[] is read from connector.json. The client parses no tools field on the
index entry, so a block placed only in fragment.json is inert. Put it in the manifest;
mirroring it into fragment.json is optional and purely documentary.
Icons are per connector, not per tool. Every tool shows its connector's icon_small;
there is no per-tool icon. Partial tools[] lists are fine — unlisted tools fall through.
3. Placement & risk vocabulary (what the words mean)
| Manifest | Meaning |
|---|---|
scope: "user" |
runs once per user, inside their container. Personal creds. Stored as per_user. |
scope: "global" |
runs once for the household, on the host. Shared, stateless. Admin enables it with a key. |
type: "mcp_local" |
ships code that will execute on the box — installing needs the admin mcp.register_local_script capability (RCE-bearing act, §14). Stored as local_script. |
type: "mcp_remote" |
just an HTTP URL; no local code. Stored as remote. |
Pick the narrowest: a personal messaging/email/calendar connector is scope: "user"; a shared
search API is scope: "global". Both axes fail closed — an unreadable scope becomes
per_user, an unreadable type becomes local_script, the answer that demands more
authority.
requires[] is a human hint rendered as tags on the catalog page: API_KEY, ENV, NODE,
PYTHON, OAUTH, DOCKER. The client only inspects it as a fallback when the manifest
declares no auth (OAUTH → oauth, API_KEY → api_key). SECRETS_DIR is removed — the
secrets/ folder is no longer part of the model.
4. Authentication (auth.type)
4a. The recognized values
auth.type |
Flow | Ships |
|---|---|---|
none |
nothing to sign in | — |
api_key |
user pastes a key/secret into a form | an env[] schema (§4b) |
oauth2 |
browser consent → token injected as an env var | provider + scopes + deliver (§4c) |
qr |
server shows a QR, user scans with a phone | a login_status tool (§4d) |
ssh_key |
reserved for key-based remote access | — |
Anything else normalises to none. email declares auth.type: "password", which the
client does not recognise and therefore treats as none; that connector works only because
its credentials arrive through env[] like any other form field. Prefer api_key or none
for new connectors and let env[] carry the credential.
auth.delivery (header | query | env) is surfaced to the admin UI. For a remote
connector the URL placeholder is what actually routes the key (§4b) — the param name is
not read by the client.
4b. env[] — the activation form, and what reaches the process
Each entry drives one form field, and the collected values are injected into the server process environment:
"env": [{
"name": "EMAIL_IMAP_HOST", // ← the ACTUAL environment variable name
"label": "IMAP host",
"description": "IMAP server hostname (e.g. imap.gmail.com).",
"required": true,
"secret": false, // true → masked in the form, stored encrypted
"example": "imap.gmail.com",
"default": "" // non-required fields only
}]
namemust be exactly the environment variable your server reads. The process receives the form'sname→ value map verbatim; there is no renaming layer.mcp_config.envis not substituted at runtime — it survives only as a legacy fallback for the form schema, and if it is ever the sole env source its{ENV:…}strings are injected literally. Do not use it to map one name onto another.
The server reads each value from os.environ / process.env. Declare requires: ["ENV"]
when the connector needs user-supplied config.
Placeholders. Two tokens, and exactly two places where they are substituted:
| Token | Meaning |
|---|---|
{ENV:NAME} |
non-sensitive value (host, port, username…) |
{SECRET:NAME} |
sensitive value (password, API key, token) |
| Substituted in | Engine behaviour on an unknown name |
|---|---|
mcp_config.url (remote connectors) |
{SECRET:x} falls back to the connector's api_key; if that is absent the token is left in the URL literally, so a misconfiguration is visible |
verify.command (§6) |
replaced with the empty string |
Nowhere else — not in mcp_config.env, not in args. Legacy {key} still resolves to the
api_key in URLs; {env:NAME} and {secrets}/… are dead and are never substituted.
Remote example: "url": "https://mcp.tavily.com/mcp/?tavilyApiKey={SECRET:tavilyApiKey}"
with a matching env[] entry named tavilyApiKey.
4c. oauth2 — provider consent
"auth": {
"type": "oauth2",
"provider": "google", // slug into the admin's sign-in providers
"scopes": ["https://www.googleapis.com/auth/gmail.modify"],
"deliver": { "as": "env", "format": "google_authorized_user", "env": "GMAIL_CREDS_JSON" }
}
The manifest names only the provider slug, the scopes, and how the obtained token is delivered — never client ids, client secrets, endpoint URLs or redirect URIs, which are admin-entered and stay off the public feed. Skald runs PKCE + code exchange and injects the credential as the named env var.
deliver.as: only"env"is implemented —"file"is rejected at activation with an explicit error rather than half-working. Do not ship it.deliver.format:google_authorized_user(the JSONfrom_authorized_user_filereads) orrefresh_token.deliver.envmust not also appear inmcp_config.env— Skald injects it at runtime.- An OAuth connector activates into a pending row; nothing starts until the consent round-trip completes. The admin must have configured the provider first, or activation fails early with a clear message.
4d. qr / interactive device login
For a connector whose credential is produced by scanning/pairing (WhatsApp today) there is no code to paste. The contract:
Expose one extra tool,
login_status, returning a JSON object (as thetextof a normal text result). Skald calls it directly — never the agent — and a login panel polls it.
// login_status result text (a JSON string):
{
"state": "connecting" | "need_scan" | "ready" | "logged_out",
"qr": "data:image/png;base64,…", // present ONLY while state == need_scan
"message": "human-readable line"
}
- Activating a
qrconnector inserts a pending row and starts the server (so it can produce the QR), then hands off to the login panel. - The panel polls
POST /api/mcp/login/status; whenstate == "ready"the connector is marked ready and starts automatically on later logins. - Also expose a
logouttool (clears the session, forces a fresh QR) — the panel calls it viaPOST /api/mcp/login/resetto re-link a different phone. - The credential is the on-disk session, not a token. Persist it inside the connector's
own directory (e.g.
./auth/next to the entry file). That folder lives under the bind- mounted home, so it survives container recreates and connector updates. Never store it under a shared or global path.
5. Dependencies (node & python) — how they get installed
Do not ship node_modules/ or vendored wheels. Declare deps as a standard manifest file
and Skald installs them where the server will run:
- node: ship a
package.jsonwith adependenciesmap. Skald runsnpm ci --omit=dev --no-audit --no-fund, falling back tonpm install --omit=dev …when there is no lockfile.node_modules/resolves automatically beside the entry file. - python: ship a
requirements.txt. Skald runspython3 -m pip install --break-system-packages --target .pydeps -r requirements.txtand puts.pydepson the server'sPYTHONPATH.
A single install has a 300 s ceiling; past that it fails rather than hanging a login.
This runs at activation and on every startup, guarded by a content hash of the
connector's source files (.skald-install.lock):
- first activation / a brand-new container → full install,
- a connector update (any shipped file changed) → re-copy + re-install,
- unchanged → skipped in microseconds.
So you never write install steps into the manifest — just ship the dep file. The manifest's
dependencies[] and setup_instructions[] are display metadata for the admin card; they
install nothing. Set requires: ["NODE"] / ["PYTHON"] as a human hint. Pin versions for
reproducible installs, and keep the tree lean (containers are slim; prefer a pure-JS/Python
library over a native-heavy one — e.g. Baileys instead of a browser).
Host assets are not copied into the container. Icons (.svg/.png/.jpg/.webp/.gif/.ico) and
connector.json stay on the host, so a server must never expect to read them at runtime.
Everything else in the folder — entry file, deps file, helper modules, verify.py — is copied.
Where the files land:
| Host | Per-user container | |
|---|---|---|
| installed folder | <workdir>/connectors/<id>/ |
~/.skald/mcp/<name>/ (/root/.skald/mcp/<name>/) |
| python deps | connectors/<id>/.pydeps |
~/.skald/mcp/<name>/.pydeps |
6. Verify-before-save (optional, strongly recommended)
Ship a verify.py / verify.js (or an inline snippet) and reference it:
"verify": { "command": "python3 verify.py", "timeout_secs": 20 }
It runs after the user fills the form and before the activation is persisted, with the
collected env/secrets injected and {ENV:…} / {SECRET:…} substituted into the command.
Output contract — one JSON object on stdout and nothing else:
{"ok": true, "message": "IMAP and SMTP authentication successful", "details": {"imap": "…"}}
{"ok": false, "message": "IMAP login failed: INVALID_CREDENTIALS"}
Exit 0 on success. If the JSON fails to parse the client falls back to the exit code (0 = ok)
and shows stderr. A timeout counts as a failure. Never print credentials in message or
details.
Rules the client enforces:
- The script must be one of the connector's shipped files. The client resolves it by
matching a basename from
files[]against the command string — sopython3 verify.pyworks becauseverify.pyis infiles[]. A script that is not shipped is never downloaded and activation fails; an inline command with no matching basename (firecrawl'snode -e "…") is fine and runs as-is. - Where it runs: inside the user's container, in the connector's directory, for a
per_userconnector; on the host inconnectors/<id>/for aglobalone. - Timeout: declare
timeout_secsfor the record, but the runtime currently applies a fixed 20 s to every verify. Keep the probe well under that. - Any
auth.typemay use verify — it is not limited toapi_key. Aqrconnector needs none: itslogin_statusis the live check.
Without verify there is no test at all. Activation goes straight to ready — including
for remote connectors, which get no handshake fallback. The manifest author decides.
7. Versioning & updates
Three fields, in both fragment.json and connector.json, kept identical:
| field | type | role |
|---|---|---|
version |
integer | monotonic build number, per connector — the machine comparison key |
version_string |
string (semver) | display only |
version_release_date |
ISO date YYYY-MM-DD |
display only |
versionis a number, not a string (8, not"8"or"2.2.0").+1on every change to any shipped file or to any manifest metadata (description, icons,version_string). Never reuse or decrement.- The integer is the only "is there an update?" signal, compared strictly
(
feed > installed).version_string, icons andllm_short_descriptionare never compared, so changing them without bumping the integer is invisible — no "update available" badge. This is the classic trap. - The manifest wins over the index when the two disagree, and the disagreement is only
visible as a log line (
marketplace feed version desync). If the index carries the lower number and the manifest the higher one, the strict comparison can never fire again and the connector silently stops offering updates. Keep them equal —compile.pydoes not check this for you. - Two propagation paths, do not conflate them:
- Per-user code + deps reconcile on a content hash of the source files (§5), so new code lands at each user's next login even without a reinstall.
- Catalog metadata (
llm_short_description→ the model's prompt, icons, friendly name) is not in that hash. It lives in the catalog row and is rewritten only by an explicit reinstall/Update, which re-pulls the current feed (never the 300 s browse cache) and restarts enabled global servers and every live user's copy with the fresh description.
- So: to ship a new
llm_short_description, bump the integer so the admin sees "update available" and clicks Update. Nothing auto-propagates a description change.
8. Hard limits the client enforces
- Digest mismatch → refused, all-or-nothing: nothing is written unless every file verifies.
- 8 MiB per file. A
files[].sizeabove that is rejected before the download starts. - Path safety. A
files[].pathormcp_config.args[0]that is absolute, contains.., a backslash or a colon is rejected outright. - A local connector with no
files[]cannot be installed ("refusing to install unverifiable code"), and neither can one whose only file isconnector.json. mcp_config.args[0]is mandatory formcp_local.- The feed is fetched as
GET /connectors.jsonplus oneGET /<folder>/connector.jsonper entry, cached 300 s for browsing; installs always refetch. A manifest that fails to load degrades that one card to whatever the index said — it never fails the listing, which is exactly how a broken manifest hides.
9. Checklist for a new connector
- Create
connectors/<id>/— flat — with: entry file,connector.json,fragment.json, deps file (package.json/requirements.txt),icon_sm.*,icon_lg.*, optionalverify.*. - Server speaks MCP over stdio (§2); stdout = JSON-RPC only; notifications answered with
silence;
titleon every tool. mcp_config.args[0]names the entry file;transportset consistently.- Correct
type+scope(§3) andauth.type(§4);auth,type,scope,tags,requiresand the version trio identical in both documents. env[].name= the real environment variable name (§4b).- For
qr: implementlogin_status+logout, persist the session under the connector dir (§4d). - Deps declared as a file, not vendored (§5).
docs[0].llm_short_descriptionsays what the connector does, not which tools it has.- Add
"<id>"toconnectors/index.json. - Bump
version(+1),version_string,version_release_datein both documents. - Run
python3 scripts/compile.py, thenpython3 scripts/compile.py --verify. - Record the change in
CHANGELOG.mdin the same commit, then commit and deploy.