CLI

Partners — an agent for each of your customers

With ghosty partner you give an assistant to every business that uses your product — one shared agent that only uses your MCP, memory per customer, and credentials for your server.

Updated 2026-10-07

ghosty partner is for when your product wants to give an assistant to each of its customers. You have a partner space; each business using your product is a tenant (identified by your own externalId) and each person writing to that business is an end user (groupId).

  • There is one shared agent, in solo-MCP mode: no machine, no disk; its only tools are those of your HTTPS MCP servers.
  • Every turn carries a short-lived token that Ghosty passes to your MCP. It never goes into the prompt, memory or logs.
  • Memory is per tenant and end user: one business never sees another's.

Your partner space

The space owner runs these, signed in (ghosty login).

bash
ghosty partner ls                                  # your partner spacesghosty partner enable <workspace>                  # turn one of your spaces into a partner spaceghosty partner credentials ls                      # server credentials (no secrets)ghosty partner credentials create --label production   # issue one: the secret is shown ONCEghosty partner credentials create --label production --out-env .env.partner   # without going through the terminal (mode 600 file)ghosty partner credentials create --label production --format env | fly secrets importghosty partner credentials rotate gpk_… --grace 60     # new secret, same keyIdghosty partner credentials revoke gpk_…            # revoke it (idempotent)ghosty partner audit                               # who did what, when
  • create prints GHOSTY_PARTNER_KEY=gpk_… and GHOSTY_PARTNER_KEY_SECRET=gps_…. Store them on your server right away: the secret is not shown again. To keep it off the screen: --out-env PATH (written with mode 600) or --format env (just the two lines, to pipe into another command). rotate takes the same.

--out-env used to be --env-file; Node 22 swallows that name as its own flag (node: ./.env.partner: not found). The old name stays as an alias.

  • rotate --grace MIN keeps the old secret valid for that many minutes (default 60) so you can switch your server without downtime.
  • creds is an alias of credentials; revoke can also be spelled rm.
  • If you own more than one partner space, pick one with --workspace <slug> on any command.

The shared agent

bash
ghosty partner agent get                           # the template and its versionghosty partner agent apply --file agent.json       # create or update it (idempotent)ghosty partner agent apply --file agent.json --prompt-file prompt.md   # the prompt in its own file (wins over the JSON one; 60,000 chars max)ghosty partner agent apply --file admin.json --agent nik-admin         # ANOTHER agent of the same partner (slug a-z, 0-9, -)ghosty partner agent get --agent nik-admin

A partner can have several agents: the default one (no --agent) and others with a slug (for example one for the business owner and a public one). Each has its own template and versions; with a slug, apply publishes directly. Over the API: PUT /api/v2/partner/agent with "slug" in the body, GET …/agent?slug=, and "agent": "<slug>" in POST /api/v2/partner/turns. A business's memory and usage span all its agents (tenants rm deletes it from all of them).

maxSteps (1-12) is how many model↔tool rounds a turn gets; if the model still asks for tools on the last one, it is asked for a final answer without them. If your MCP sends instructions in its initialize (e.g. how to use a catalog with discover_tools/run_tool), they go into the prompt (up to 6,000 chars).

maxTokens (256-8000, default 2000) caps each model reply and thinking (true/false, default false) turns on DeepSeek reasoning: slower and pricier, useful for a multi-step assistant.

With "engine": "claude" (e.g. "model": "claude-sonnet-5-5") the agent runs with a box (claude-worker) instead of solo-MCP: same turns contract and same SSE, but each conversation is sandboxed (one business can't see another's files or history), it has none of the space account's tools, and the turn token reaches your MCP through a Ghosty proxy (it never enters the box). Every MCP server must carry ${turn.token} in a header. The Claude key is the space's (or the house one if the space has it enabled).

Turn robustness. If your MCP doesn't answer at startup it is retried once and, if still down, the turn ends with error (it doesn't answer without tools). A provider 429/5xx is retried before the first chunk. If a tool answers 401 mid-turn (expired token) and the turn started in Ghosty (WhatsApp) or carries agent, a fresh token is exchanged at /api/nik/turn-token and the call is retried once. Read tools (get_, list_, search_, find_) in the same step run in parallel, and the ids each turn saw stay in its memory for the next one.

Limits. Besides the per-partner limit there is one per business (8 concurrent turns) and one per conversation (20 messages per minute); past them, turns answers 429.

agent.json carries only what you want to change:

json
{  "name": "Nik",  "engine": "deepseek",  "prompt": "You are the assistant of {business}…",  "mcp": [    { "name": "agenda", "url": "https://api.your-product.com/mcp", "headers": { "Authorization": "Bearer ${turn.token}" } }  ]}

HTTPS MCP servers only. Headers take no fixed keys: only the ${turn.token} and ${tenant.externalId} placeholders. If nothing changed, no new version is created.

Tenants

bash
ghosty partner tenants lsghosty partner tenants get org_123ghosty partner tenants set org_123 --config '{"name":"Spa Luna","timezone":"America/Mexico_City"}'ghosty partner tenants rm org_123                  # deletes its config AND its memory

A tenant is config only (name, tone, timezone, locale, notes): it has no machine of its own. The first turn for a new externalId also registers it.

Usage per business

bash
ghosty partner usage                                   # last 30 days, every businessghosty partner usage --tenant org_123 --from 2026-10-01 --to 2026-10-07

Over the API, signed with the credential (GET with an empty body): GET /api/v2/partner/usage?externalId=&from=&to= → per business turns, tokens, costUsd, a per-day breakdown (Mexico City days) and errors by reason (mcp_down, provider_429, token, timeout, cancelado, reinicio…). from/to as YYYY-MM-DD or ISO; 92 days max.

Try a turn

bash
ghosty partner try org_123 "what appointments do I have today?"ghosty partner try org_123 "and tomorrow?" --group customer-42 --turn-token <short-lived-token>
  • --agent <slug> tries another agent of the partner (default: the default one).
  • --group is the end user (default cli); each one gets its own conversation.
  • --turn-token is the token Ghosty will pass to your MCP as ${turn.token}. Here --token works too and means the same (it is not your session).
  • --verbose shows each tool's arguments (without secret-looking keys) and how long it took: ↳ denik__buscar_citas {"fecha":"2026-10-01"} · ✓ denik__buscar_citas 340 ms.
  • With --json it prints one line per turn event (chunk, tool with args and ms, error…).

Test as your server

With the credential in the environment, the CLI signs every request the way your backend will (HMAC of ${ts}.${keyId}.${body}):

bash
export GHOSTY_PARTNER_KEY=gpk_…export GHOSTY_PARTNER_KEY_SECRET=gps_…ghosty partner agent getghosty partner try org_123 "hi"

An agent's WhatsApp groups

One of your agents (e.g. nik-admin) can have its own WhatsApp number (paired with ghosty channels wa …). Each group it is in gets linked to a business, and from then on it answers everything there with that business's tools:

bash
ghosty partner wa groups ls --agent nik-admin                       # groups, the business each one goes to, on/offghosty partner wa groups link 1203…@g.us --tenant org_123 --agent nik-admin   # link a group the number is already in and turn it onghosty partner wa groups create --tenant org_123 --name "Nik · Spa Luna" --agent nik-admin   # new group + invite link (max 10 per hour)ghosty partner wa groups unlink 1203…@g.us                          # unlink and turn off; the number stays in the group

Over the API: GET|POST /api/v2/partner/wa/groups and PUT|DELETE /api/v2/partner/wa/groups/{jid}. On each turn Ghosty asks your server for the token with {"externalId","aud":"<agent slug>","groupId":"<jid>"}; your answer may also carry appendSystemPrompt (≤ 4,000) and config (name, timezone…). If you answer 403/404, that message gets no reply (it shows in ghosty turns ls <id> --skipped). The sender's name goes in the text (María: …); attachments arrive as signed URLs and voice notes transcribed.

A business's WhatsApp Business number

One of your businesses' WhatsApp Business number can be answered by one of your agents (e.g. nik-public). If you register the number in Formmy yourself, ask Ghosty for the link and give it to Formmy; none of your keys travel:

bash
ghosty partner waba links create --tenant org_123 --agent nik-public   # prints url and secret (the secret ONCE); born offghosty partner waba links set <id> --integration <formmy-id> --phone 5215550001111 --on   # what Formmy returned, and turn it onghosty partner waba links ls --tenant org_123ghosty partner waba links rm <id>                                      # turn off; history stays

Formmy gets url as externalAgentUrl and secret as externalAgentSecret, and posts each message to ${url}/message with Authorization: Bearer <secret>. Over the API: GET|POST /api/v2/partner/waba/links and PUT|DELETE /api/v2/partner/waba/links/{id}.

If the number is already in Formmy and you move it from Ghosty:

bash
ghosty partner agent get --agent nik-public        # its idghosty channels whatsapp link <id> --integration <formmy-id> --answer on --partner-tenant org_123 --partner-agent nik-public --dry-run

On each message Ghosty asks your server for a short-lived token (POST /api/nik/turn-token signed with your credential: X-Ghosty-Key, X-Ghosty-Ts, X-Ghosty-Sig = HMAC of ${ts}.${keyId}.${body}; body {"externalId","aud","groupId"}) and runs the turn with it. The groupId is wa:<phone>. If your server doesn't return a token, the message is recorded without a reply (ghosty turns ls <id> --skipped).

GHOSTY_PARTNER_SECRET still works (the old name), but don't mix it up: Ghosty's server uses that same name for a secret of its own. Use GHOSTY_PARTNER_KEY_SECRET.

That works for agent, tenants and try. ls, enable, credentials and audit always use your session: a credential cannot mint other credentials.

A boxed agent for your users

If instead of the shared MCP-only agent you want a normal agent (Claude with a box, shell and files) that serves each user of your product with THEIR key:

bash
ghosty spaces create mycompany && ghosty partner enable mycompanyghosty partner credentials create --label production    # gpk_ + gps_ghosty mcp set <agent> --file servers.json              # "Authorization": "Bearer ${turn.token}"

Each turn: POST /api/v2/fleet-agents/<agent>/message-stream with {groupId, text, turnToken} (one groupId per end user; turnToken 8 to 4096 characters, no whitespace) and the headers X-Ghosty-Key: gpk_…, X-Ghosty-Ts (epoch seconds) and X-Ghosty-Sig = hex HMAC-SHA256, keyed with gps_…, of ${ts}.${keyId}.${body}. The response is SSE (chunk, tool, done, error). The token only reaches your MCP: it never enters the box and is never stored. Without turnToken, the turn fails with error. The credential only reaches the normal agents of your workspace.

Landing pages for your businesses

Your server can ask Ghosty for a business's landing page built from its real data, section by section, to paint it in your own editor. Same signature as turns (X-Ghosty-Key, X-Ghosty-Ts, X-Ghosty-Sig). Nothing is stored or published on Ghosty: the HTML is yours.

POST /api/v2/partner/pages/build (SSE):

json
{  "externalId": "org_123",  "brief": "Make it feel unhurried",  "facts": {    "name": "Estética Luna", "description": "Facials and waxing", "category": "beauty salon",    "city": "Puebla", "phone": "222 123 4567", "whatsapp": "522221234567",    "address": "5 de Mayo 210, Centro", "hours": "Mon–Sat 10:00–19:00",    "services": [{ "name": "Deep facial", "price": 650, "duration": "60 min", "url": "https://your.app/luna/facial" }]  },  "photos": [{ "url": "https://cdn.your.app/luna/front.jpg", "alt": "Storefront" }],  "theme": { "primary": "#be185d", "mode": "light" },  "sectionsWanted": 6,  "dialect": "denik",  "review": false}

Events, each one data: {…}:

typeCarries
plantitle, theme (resolved colors, fonts, radius) and sections: [{id, kind, title}] in order
sectionid, index, html: one per section, as it comes out
section-updatethe same section again, now with photos and icons (or fixed by the review)
reviewonly with review: true: problems, fixes and skipped
donefinal sections: [{id, kind, title, html}], theme, usage {input, output}, seconds
errormessage
  • Real data: no invented figures, years, reviews, testimonials or services. Without services there is no services section; testimonials, stats, team and pricing are never requested. Every service with a url gets a «Agendar» (book) button to that link.
  • Photos: yours fill the image slots first (in order); the rest come from free stock banks, picked with vision. CC photos carry data-credit: render the credit in the footer.
  • dialect: gs (default) uses Ghosty's builder tokens; denik leaves classes that bakeTailwindHtml compiles and that follow your brand kit (no surface-deep, fonts as style, https photos without crossorigin, no data-id). Both keep data-animate.
  • Limits: 4 builds at a time per partner and 20 per business in 24 h (429). Spend is recorded in your agent's usage, per externalId.

POST /api/v2/partner/pages/review with {html, viewport?, theme?, externalId?, dialect?} opens it in a real browser (desktop 1280 and mobile 390) and returns {problems: [{sectionId, kind, detail, viewport, nodeId}], fixes, sections, skipped, seconds}; kind ∈ broken-image, overflow, clipped, overlap, contrast, contrast-unsure, dead-link. sections holds only the ones the review fixed. If the page could not be rendered it answers 503, never an empty list.

Animation: copy (3 KB, CSS + JS) once before </body> of your published page. Without it the page is complete, just still.