Publish a Module

Signed handler bundles that add new providers or capabilities. Free by default; flip one flag to sell them with offline-verified licenses.

Building against a specific gotcha? The Publisher FAQ has straight answers to what other publishers have already asked — vault_get semantics, MCP tools/list visibility, local-UI composition, licensing, review process.

Module anatomy

A module is three files under ~/.railcall/station/modules/<slug>/. The loader verifies the Ed25519 signature before importing the handler — a bad signature refuses to load.

text
~/.railcall/station/modules/your-handle/example/
├── module.json         # manifest — slug, commands, license flag
├── handlers/
│   └── handler.py      # your handler code
└── module.sig          # Ed25519 signature over the bundle

module.json

The manifest declares the commands your handler exposes and whether the module is gated behind a license. Keep it minimal.

json
{
  "slug": "your-handle/example",
  "version": "0.1.0",
  "description": "Short one-liner shown on the listing.",
  "commands": [
    { "name": "do_thing", "description": "What this command does." }
  ],
  "license_required": false
}

Signing & publishing

railcall market publish signs the bundle with your publisher key and uploads it. Users install the same way they install workflows.

shell
railcall market claim your-handle/example
railcall market publish
# ↳ writes module.sig
# ↳ uploads to railcall-marketplace-lggm.onrender.com

Flip license_required: true in module.json. The loader then refuses to import the handler unless a valid entitlement is present for this install.

json
{
  "slug": "your-handle/example",
  "version": "0.1.0",
  "license_required": true
}

Licenses are minted by the RailCall license service (railcall-license.onrender.com), bound to the buyer's install pubkey, and verified offline against the pinned issuer public key. Seven-day grace past expiry so a flaky network doesn't break production.

Live example: sami666/salesforce

Thirteen commands across leads, contacts, accounts, opportunities, and cases. OAuth 2.0 refresh flow via railcall connect salesforce. $199/mo. That's the reference shape for a paid connector.

Live example: sami666/hubspot

Two commands (create_contact, log_note), Bearer token auth, free. That's the reference shape for a free connector.

What buyers do

shell
railcall market install your-handle/example
railcall license activate your-handle/example    # only if license_required
railcall license verify                          # offline
Signature discipline
Never publish a module by hand-editing module.sig. The publish command re-signs on every upload; a stale or forged signature bounces at load time and users see the failure.

Credentials + sandbox (v0.56+)

credential_spec works with catalogue providers. If your module declares a provider name that exists in the built-in catalogue (salesforce, hubspot, zoho… ~110 ids), your custom fields render in Studio's Configure form auto-namespaced as your-slug::provider— your handler's vault_get("provider") routes there transparently. No handler changes.

Network allowlists actually enforce — and actually work. Declare requires.network: ["api.example.com"] and standard requests/urllib calls to allowed hosts pass (the pre-v0.56 DNS-resolution false-block is fixed), while undeclared hosts and raw-IP dials refuse loudly. Auth reads must come from the vault — os.environ credential reads fail review.

The `requires` block — full reference

Without a top-level requires block your module runs with Studio's full privileges and shows SANDBOX · UNRESTRICTED in the Modules tab. Declaring one is opt-in, never breaking, and flips the badge to your declared capabilities — buyers see exactly what your handler may touch.

json
{
  "id": "your-handle/linear-guard",
  "requires": {
    "network":           ["api.linear.app", "*.optional-wildcards.com"],
    "subprocess":        false,
    "filesystem_writes": []
  },
  "commands": [ ... ]
}
  • network — hostname allowlist for outbound connections (wildcards ok). Anything not listed raises SandboxViolation with the offending host in the message. Empty list = no network.
  • subprocess — boolean. false makes any subprocess.* call raise.
  • filesystem_writes — glob list of writable paths (e.g. ["/tmp/**"]). Empty list = no writes. Reads are not restricted — only writes.
Two fields named `requires` — don't mix them up

The top-level requires (shown above) is the sandbox capability block. The per-command commands[].requires is a list of credential names that command needs — a completely different meaning. Also distinct: allowed_destinations governs the egress-broker checks, not handler socket calls. Declare both when they apply.

Changing it requires a re-sign. requires lives in module.json, and the bundle signature covers the canonical manifest — an edited manifest without a re-sign fails the loader's signature check. So: edit → railcall market module sign → bump the version → railcall market publish. The station reads the block at module load.

Honest scope: this is language-level enforcement — designed to catch capability drift and AI-drafted mistakes loudly, not to contain a determined adversary. Publisher signatures + the trust allowlist remain the primary defense; OS-level sandboxing (sandbox-exec / systemd IPAddressDeny) layers on top where available.

Revenue

You keep 95% of your listing price — on one-time sales and on every subscription renewal.The buyer additionally pays a 10% assurance fee on top of your price (their own line item at checkout, effective 26 Aug 2026) — it funds the airlock itself: signature verification, provenance, blast-radius vetting and revocation on every module. Your economics never change because of it. Wallet-and-payout model — earnings accumulate in your marketplace wallet and you withdraw when you're ready. Example: a $100 module → buyer pays $110, you keep $95, RailCall's $15 covers the 5% take plus the buyer's assurance fee.