Publisher FAQ
Straight answers to the questions contest publishers actually ask. Every entry here started as a real support thread — this page just makes the answers reachable without one.
Hit a 400 on publish or seeing
commands: 0? Every lint code from the pre-publish quality gate is documented at Publish rejections.The one hard rule
Every filesystem write, network call, or state change your module promises the user MUST flow through the RailCall airlock: preview → human approval → execute → signed Ed25519 receipt. This is not negotiable and it is the entire product. A module that writes files or hits third-party APIs outside the airlock fails review, no exceptions.
My module calls vault_get("provider") and gets None even though I saved the credential in Studio Integrations.
Fixed in Station v0.29+. Prior versions had two credential stores (keys.local.json legacy + credentials.local.json Phase-2 named-credential vault) and vault_get() only read the first. Studio → Integrations writes to the second, so a credential you tested successfully still resolved to None in the handler.
Upgrade with curl -fsSL railcall.ai/install.sh | sh (or your existing install path). The resolver now falls through: legacy first (byte-for-byte unchanged for existing installs), then the default credential from the Phase-2 vault. Same shape modules already expect — {"api_key": "..."}.
Do NOT restore a direct read of credentials.local.json from your handler — that path is a moving surface owned by Studio. Use __rc_helpers__["vault_get"] exclusively.
My module loads fine in Studio, but its commands don't appear in MCP tools/list.
Fixed in Station v0.29+. The railcall mcp server (workbench/mcp_server.py) now scans the same modules directory Studio uses on every tools/list call and merges your commands under <slug_tail>.<command_id> names — matching the Studio Modules-tab chips + Sends provider grouping.
A fresh railcall market install propagates on the very next tools/list without an MCP-server restart.
tools/callfor a namespaced module name returns an actionable pointer at Studio's airlock plus the exact railcall airlock stage <name> --inputs <json> command (isError: true so hosts render it as a real failure the operator can act on). Execution stays in the airlock intentionally — running a module handler needs the signature-verify + trust + license + sandbox chain that lives in Studio.
What's the correct shape for input_schema in module.json?
Publishers today write a flat, name-keyed shape (matches Studio's internal arg_specs):
"input_schema": {
"content": {
"type": "string",
"required": true,
"help": "Message body (max 2000 chars)"
},
"channel": {
"type": "string",
"required": false,
"placeholder": "#deploys"
}
}Station v0.30+ normalizes this into valid MCP JSON Schema automatically on every tools/list. Real JSON Schema (has type or properties at the top level) is also accepted and passed through. Either shape works.
Per-field keys the normalizer honors:
type—string,number,integer,boolean,object,array.textis normalized tostring.required—trueadds the field torequired[]at the schema top level.helpandlabel— surface as the propertydescription.options— surface as JSON Schemaenum.default— passed straight through.
Rule of thumb: if the field name isn't self-explanatory to an LLM, add a help. If it's constrained to a set of values, add options. Both surface to MCP hosts driving your tool automatically.
Can my module launch a local UI (Electron / browser) that helps the user prep inputs?
Yes — with one hard line you cannot cross: the UI can never write files or hit external APIs directly. Every side effect goes through the airlock via a module command.
Two acceptable shapes:
Shape A (recommended): UI is a Studio companion
User launches your UI (Electron, Tauri, a local server your module spawns). UI helps them pick a file, runs analysis, shows detected issues + proposed repairs. When they hit "apply", UI POSTs the repair plan to Studio's /api/commands/preview endpoint. Studio renders the airlock card in Sends, the operator approves there, receipt mints. Your UI can poll for the receipt id and show a success screen.
Shape B: module handler spawns the UI on demand
Command shopify.repair_csv_interactive fires the UI as a subprocess when called via railcall airlock stage. UI collects parameters, hands them back (stdin / localhost socket / temp file), handler stages the airlock write. Cleaner for CLI users; more moving parts.
Default to Shape Aunless there's a specific reason for Shape B.
- Do not have the UI write files or call APIs directly.
- Module MUST work end-to-end from the CLI (
railcall airlock stage). Reviewers test that path. "Please open the UI" is a failed review. module.sigcovers the module dir only. A separately- installed UI is trusted on its own reputation — say so in the description.- No silent phone-home from the UI. Every side effect is an airlocked command.
input_schemamust reflect what the CLI accepts, not a UI-trimmed shape.
What does module.sig actually cover?
The Ed25519 signature covers the exact bytes of module.json plus handlers/handler.py in the module directory. It does NOT cover:
- External files your handler reads from the user's disk.
- Subprocess binaries your handler spawns.
- Separately-installed helper apps or UIs.
- Runtime state (vault contents, receipts). Those are signed independently by Studio's install-time keypair.
The trust chain answers "did the publisher who claims to have shipped this bundle actually sign it?" — a strong guarantee, but scoped to the bundle itself.
How do I test my module before publishing?
Put your module under ~/.railcall/modules/<your-slug>/ with the required files (module.json, handlers/handler.py, module.sig). Studio's loader picks it up on startup + on every /api/modules/reload.
The five-step loop we recommend:
- Sign your bundle:
railcall market module sign path/to/module_dir. - Drop it under
~/.railcall/modules/. Open the Studio Modules tab — you should see it load green with all commands registered. - Fire a smoke test via CLI:
railcall airlock stage <cmd> --inputs '{...}', thenrailcall airlock approve <staging_id>. Verify the receipt lands under~/.railcall/workspace/receipts/. - Fire the same command via MCP Inspector against
railcall mcp. It should return an airlock pointer (not execute) with a valid JSON Schema. railcall market publish path/to/module_dir. Review takes ~24h.
Why did my module get rejected on install?
Studio's Modules tab shows the exact rejection reason on every rejected card. The five main classes:
- Signature failure —
module.sigdoesn't verify against the publisher pubkey inmodule.json. Re-sign with the correct key. - Trust: publisher not in allowlist— the operator has strict trust mode on and your publisher pubkey isn't on their list. They run
railcall trust add <pubkey>once and your module registers. - License: not activated / expired / bound to wrong install — for paid modules. The Modules tab surfaces "Buy license" + "Activate license" CTAs.
- Command
<cid>: no callable_h_<name>in handler.py —module.jsondeclares a command whose handler function is missing or misnamed. - manifest parse error —
module.jsonisn't valid JSON.
When do I need to bump the module version?
On every publish. The marketplace refuses a publish that reuses an already-published version number for your slug. Bump the version field in module.json (semver: major.minor.patch). SemVer guidance:
- Patch: bug fix, docs, no behavior change for existing callers.
- Minor: new command added, or a new optional field on an existing command.
- Major: a breaking change — an existing command removed, a required field added, a receipt shape changed.
How do reviewers test my module?
Reviewers install your module on a fresh Station install and:
- Verify the signature loads clean (green ✓ in Modules tab).
- For each declared command, fire a
railcall airlock stage <cmd>from the CLI with the required inputs from yourinput_schema. - Approve in Studio Sends → verify a signed receipt lands.
- Check the receipt has no secrets in it (only message hash + recipient metadata).
- Run your provided smoke test script if one is documented.
The CLI path is the ground truth. Any UI helper you ship is optional; the CLI path must produce the same receipts.
How does licensing work for paid modules?
Set license_required: true in module.json. Choose a price + tier when publishing (one-time or subscription). Buyers hit Stripe Checkout on the marketplace listing; on completion, our issuer signs an Ed25519 license bound to their install pubkey and drops it under ~/.railcall/workspace/module_licenses/ via the railcall market claim flow (or auto-claim when their install pubkey is already known to us).
Your handler doesn't verify anything — the loader gates access before your handler runs. If the license is missing/expired/foreign, the module lands in the rejected list with a "Buy license" CTA and your _h_* functions never register. Free modules skip this entirely.
Subscriptions auto-renew via Stripe's invoice.paid webhook — a fresh license file overwrites the old one before it expires. A grace period covers short-term Stripe outages so a valid subscriber never has their module go dark from a webhook delay.
Does my module handler run in a sandbox?
Opt-in as of Station v0.33. Declare capabilities in your module.json:
{
"requires": {
"network": ["api.linear.app", "*.stripe.com"],
"subprocess": false,
"filesystem_writes": ["/tmp/**"]
}
}The loader monkey-patches your handler namespace before exec so any attempt outside the declared capabilities raises SandboxViolation:
- network — fnmatch-style host allowlist. Empty list = no egress at all. Wraps
urllib.request.urlopen,http.client.HTTPConnection, andsocket.socket.connect. - subprocess — boolean. False replaces
subprocess.Popen/run/call/check_output+os.system/popen/exec*/spawn*with a raise-immediately shim. - filesystem_writes — glob allowlist for absolute paths. Wraps
open(mode=w|a|x|+)+os.remove/unlink/rename/replace/rmdir/mkdir/makedirs. Reads are unrestricted.
Modules WITHOUT a requires block behave exactly as before — no forced migration, no breaking change. Modules WITH one get gated per declaration.
import ctypes to reach libc, low-level _socketimports, raw file descriptors. The publisher-trust allowlist is still the primary defense; this layer is "declared capabilities + fail-loud on violation." Sufficient for the ~99% class of "oops the AI-drafted module tried to shell out" and "this module quietly started talking to a domain it didn't announce." Not sufficient against a determined adversary — that's what a future real-subprocess-isolation phase would be.Studio's Modules tab surfaces the declared capabilities per module. A module with no requiresblock gets a visible "unrestricted" banner so operators aren't misled into thinking a legacy module is sandboxed when it isn't.
What happens if two modules declare the same command_id?
The loader deduplicates by command_id — the module loaded later wins. Load order is alphabetical by directory name under ~/.railcall/modules/, so a module named zzz-override-slack overrides a slack module's slack.message.post command. Explicit warning appears on Studio startup log; the Modules tab shows both cards but the Sends tab only registers the winner.
Namespace your command ids under your slug (myco.action rather than bare action) to avoid accidental collisions with other publishers' modules.
Will my module keep working when Station upgrades?
The __rc_helpers__ surface Studio injects into handlers is API-stable — vault_get, jload, jsave, safe_namewon't break. Handlers that use only these keep working across Station upgrades.
Handlers that reach into Studio's internals (importing from workbench.*, reading undocumented state files) may break — those aren't public surface. If you need something that isn't in __rc_helpers__, open an issue and we'll add it.
Where do I report bugs or request features?
Email sami@railcall.ai with:
- Your Station version (
railcall version). - The module slug + version you're testing.
- The specific command / MCP call that failed.
- The Studio log excerpt (stderr) around the failure — never full receipts (may contain PII).
Contest publishers get a same-day platform-fix turnaround when the bug is real (Muhammad's vault_getgap and Rayan's MCP visibility gap were both diagnosed + fixed + shipped inside 24h).