Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Organizations, API keys and agents

7. Organizations, API keys and agents

7.1 The organization model

There are no users, passwords or teams. An organization owns channels, posts, media, notifications and API keys. The API key identifies the organization on every request. A deployment can have several organizations; each key sees only its own rows.

rustiz init --name "Acme"             # name used only when it creates the first org
rustiz org create "Acme EU"           # prints organization id + API key
rustiz org list                       # id<TAB>name, one per line
rustiz org rotate-key <org-id>        # prints the new key

rustiz org ... prints plain text, not JSON. rotate-key also revokes every MCP OAuth access/refresh token issued to that organization; clients must re-authorize or be reconfigured with the new key. Channel credentials are not deleted by a key rotation.

rustiz init is idempotent: it keeps an existing JWT_SECRET, keeps the stored API key if it still matches an organization, and otherwise adopts the first existing organization rather than creating a second one and writes that organization’s key to config.env. It only errors if the adopted organization has no API key at all (then rustiz org rotate-key <id> is the fix).

7.2 Authentication

Send the key as either:

Authorization: Bearer <api-key>

or the raw value (what Postiz sends):

Authorization: <api-key>

/mcp/{key} does not exist in this port. Keys never travel in URLs or logs. The server hashes MCP OAuth tokens with SHA-256 and compares the hash; API keys are stored in the organization row.

7.3 MCP over stdio (local agent)

rustiz init prints the exact block. It looks like:

{
  "mcpServers": {
    "rustiz": {
      "command": "/usr/local/bin/rustiz",
      "args": ["mcp"]
    }
  }
}

The key is read from RUSTIZ_CONFIG/~/.rustiz/config.env; no secret appears in the snippet. rustiz mcp also starts the scheduler and, for a loopback HTTP FRONTEND_URL, the login callback listener. You can override the key:

rustiz mcp --api-key <key>

stdout carries the MCP protocol; diagnostics go to stderr. A service manager that captures stdout must not inject banners into it.

If the machine has no config, rustiz mcp fails with a message pointing at rustiz init rather than complaining about JWT_SECRET.

7.4 MCP over HTTP (remote agent)

Endpoint: ${FRONTEND_URL}/mcp.

Bearer API key (simplest):

{
  "mcpServers": {
    "rustiz": {
      "type": "http",
      "url": "https://social.example.com/mcp",
      "headers": {
        "Authorization": "Bearer <api-key>"
      }
    }
  }
}

The transport is stateless Streamable HTTP. Tool calls normally return JSON; subscriptions/listen uses an event stream. Host must be in the allowlist: FRONTEND_URL’s host (and host:port), localhost, 127.0.0.1, plus anything in MCP_ALLOWED_HOSTS.

OAuth 2.1 + PKCE (for clients that support it): omit the Authorization header. A request without credentials gets 401 with:

WWW-Authenticate: Bearer resource_metadata="https://social.example.com/.well-known/oauth-protected-resource/mcp"

Discovery and endpoints:

EndpointPurpose
GET /.well-known/oauth-protected-resource and .../mcpRFC 9728 resource metadata (resource, authorization_servers, bearer_methods_supported, scopes_supported: ["rustiz"]).
GET /.well-known/oauth-authorization-serverRFC 8414 authorization-server metadata.
GET/POST /oauth/authorizeConsent page. It asks for the organization API key; there is no user login.
POST /oauth/tokenAuthorization-code and refresh-token grants, token_endpoint_auth_method: none.
POST /oauth/registerRFC 7591 dynamic client registration (for older clients).

Details an operator may need:

  • Only response_type=code, S256 PKCE and a code challenge of at least 43 characters are accepted. resource must be ${FRONTEND_URL}/mcp or ${FRONTEND_URL}.
  • Client ID Metadata Documents are supported: a client id that is an HTTPS URL (or a plain-HTTP URL when ALLOW_PRIVATE_NETWORK=true) is fetched through the SSRF-safe client, max 64 KiB, and must contain a matching client_id and redirect_uris.
  • Registration accepts https URIs and http only for loopback hosts (localhost, 127.0.0.1, [::1]); at authorization the requested redirect_uri must match a registered one exactly, except that a loopback redirect may use any port (RFC 8252). javascript:, data:, file:, blob:, ftp:, ws:/wss: and fragments are rejected. At most 10 URIs, 2048 bytes each.
  • Authorization codes live 5 minutes and are single-use. Access tokens live 1 hour; refresh tokens live 30 days and are rotated on use.
  • The consent form is the only HTML the server renders besides the channel connect result page. It is protected by X-Frame-Options: DENY, a restrictive CSP, Referrer-Policy: no-referrer and Cache-Control: no-store.
  • Registering a client grants nothing until someone enters a valid API key on the consent page.
  • Revoke everything for an organization by rotating its API key.

7.5 Connecting a channel from an agent

REST and MCP share one code path.

rustiz channels list
rustiz providers --status
rustiz channels connect x --open
rustiz channels connect mastodon-custom --external-url https://mastodon.social
rustiz channels connect bluesky --fields ./bluesky.json
rustiz channels connect telegram
rustiz channels connect-status <connectionId>

--fields takes a JSON file or - for stdin. --timeout defaults to 300 seconds for the in-process OAuth wait.

MCP connectChannelTool arguments:

ArgumentUse
providerProvider identifier. Not needed when polling a connectionId.
connectionIdPoll a Telegram/Moltbook/Farcaster/OAuth handshake started earlier. Safe to repeat.
externalUrlSelf-hosted instance URL (required for mastodon-custom).
customFieldsCredential object for customFields providers. Telegram also accepts {"chatId":"-100..."}, Moltbook {"apiKey":"..."}, Farcaster {"signerUuid":"..."}.
nameMoltbook agent name to register.
descriptionMoltbook agent description.

If customFields is present and is a JSON object, it takes precedence over the OAuth flow for any provider. Only send it to providers whose integrationSchema lists customFields.

Special flows:

  • Telegram (telegramBot): with no customFields, the server returns connectionId, a word and a /connect <word> command. A human adds the bot to the target group/channel (ideally as admin), sends the command, then the agent polls with the same connectionId. With customFields.chatId it connects immediately.
  • Moltbook (moltbookClaim): the server registers an agent and returns claimUrl (and possibly a verification code). The human claims it, then the agent polls.
  • Farcaster (signer): the server creates a Neynar signer and returns approvalUrl/qrCode; the human approves in the Farcaster app, then the agent polls. With customFields.signerUuid of an already-approved signer it connects immediately.
  • Between-steps channels (is_between_steps: Facebook pages, Instagram accounts, LinkedIn companies, YouTube channels, Google Business locations, Tumblr blogs): the OAuth login creates a channel with inBetweenSteps=true. List the choices with triggerTool and finish with channelSetupTool:
{
  "integrationId": "<id>",
  "methodName": "pages",
  "dataSchema": []
}
{
  "integrationId": "<id>",
  "data": { "page": "<page-id>" }
}

triggerTool only allows function names declared in the provider’s integrationSchema (functions array); it refreshes an expired token once and disconnects on a hard auth error. channelSetupTool rejects a channel that is already set up and reports That page is already connected as another channel on a uniqueness conflict.

OAuth providers need a human to open the URL. MCP clients that declare URL elicitation receive the link through the multi-round-trip flow; other clients get {connected:false, pending:true, connectionId, url, message} and should poll with connectionId or re-check integrationList.

7.6 Posting workflow

Typical tool order:

providersStatusTool
integrationList
integrationSchema { platform }
connectChannelTool (if needed)
validatePostTool
schedulePostTool
postStatusTool
  • validatePostTool checks channel state (disabled, refresh needed, between steps), provider validate_settings, character count (HTML stripped, counted in characters), thread support and check_validity media rules. It does not create anything.
  • schedulePostTool accepts socialPost[] with integrationId, date (UTC), type (draft/schedule/now), optional shortLink, settings as [{key,value}] and postsAndComments ([0] is the post, the rest are replies). attachments may be a URL string or an object with path, alt, thumbnail, thumbnailTimestamp.
  • Resends are safe: an identical call within 10 minutes, or the same idempotencyKey within 24 hours, returns the first result. Reusing a key with different arguments is rejected. An in-flight or abandoned claim tells you to check postsListTool before retrying with a new key.
  • With the MCP Tasks extension, type:"now" returns a durable task handle; otherwise poll postStatusTool. Task state is derived from posts and survives restarts.
  • subscriptions/listen on rustiz://posts/<postId> or rustiz://groups/<groupId> sends a hint on state change; it does not carry content or errors.