Docs How it works
Integrations and trust
Integrations are @steward's hands: GitHub, Railway, email, social posts, Stripe and Cloudflare DNS. Each one works for one business at a time, and each one goes through the action gate and the trust ladder.
Open Integrations and trust in the sidebar. Only a business's owners see its page.
Turning one on
An integration is On for a business when all three are true:
- Its env vars are set on the server. The page shows each one as Set or Missing, by name. It never shows the value.
- Its settings are filled in on the page (a repository, Railway ids, a from address...).
- An owner ticked Switched on for that business.
If any of these is missing, @steward's tool for it answers with what's missing and does nothing.
Secrets stay in env vars. The page only stores settings that aren't secret.
Most secrets can be set for the whole studio or for one business. The business's own env var wins. Its name is the base name, an underscore, and the business's slug in capitals, with dashes as underscores. For example, STRIPE_SECRET_KEY_DOG_WALKS for the business dog-walks.
The trust ladder
Every action has a level, per business:
| Level | What happens |
|---|---|
| Shadow | @steward logs what it would have done. Nothing happens. |
| Suggest | A decision card: Approve, Change it or Decline. It runs only on Approve. |
| Spot check | It acts, then posts what it did: Looks good, or Roll back (or Flag it when it can't be undone). |
| Auto | It acts alone and posts one line saying what it did. |
Where each action starts depends on its risk:
| Risk | Starts at | Can reach |
|---|---|---|
| internal (the studio's own records) | auto | auto |
| public (others can see it) | suggest | auto |
| person (contacts a real person) | suggest | auto |
| money | suggest | auto |
| irreversible | suggest | suggest, always |
How it moves:
- Up. After 5 approvals in a row without changes, owners get a card: "Let @steward do this on its own here?" Yes moves it up one level. At suggest, approvals are Approve on a card. At spot check, they are Looks good. Set
TRUST_PROMOTE_AFTERto change the 5. - Down. Any Decline, Flag it or Roll back drops it one level. So does an automatic rollback after a failed deploy. It never drops below suggest: a decline means "not this one", not "stop asking". Only an owner can set shadow.
- Change it resets the count but doesn't drop the level. Reply on the card to say what to change.
- By hand. An owner can set any level in the Trust section. Irreversible actions can't go above suggest.
Every step is in the ledger: action.shadow, action.run, action.rollback, trust.outcome and trust.level.
GitHub
Open a pull request from a branch, comment on one, and merge one.
- Env:
GITHUB_TOKEN, orGITHUB_TOKEN_<SLUG>. Use a fine-grained token limited to the business's repository, with Pull requests and Contents read and write. - Settings: Repository (owner/name), Main branch (default
main), and Merging to the main branch deploys. - Risk: all three are public.
- Merging into the main branch of a repository that deploys on merge counts as a deploy. It uses the deploy's trust level, not the merge's. A merge is pinned to the commit that was approved.
- Rollback: an opened pull request is closed, and a comment is deleted. A merge has no rollback.
- Tools:
github_open_pr,github_comment,github_merge,github_prsto read open pull requests,github_diffto read a pull request's changes (read only; the security loop uses it), andgithub_read_fileto read one whole file (read only, up to 200 KB; the way to get a page's full HTML forpublish_page). - Taint: pull requests, diffs and files written only by the studio's own GitHub accounts don't taint the job that reads them. Those are the login that owns the business's token, plus any in
STUDIO_GITHUB_LOGINS(comma-separated). An outside author or committer, a fork, a commit GitHub can't tie to an account, or a failed lookup taints it as before. - With the security loop on,
github_mergeis refused while the pull request's review has open critical or high findings, or hasn't run yet. See Loops.
Railway
Deploy the service, read deployment status, and roll back.
- Env:
RAILWAY_API_TOKEN, orRAILWAY_API_TOKEN_<SLUG>. This is an account or workspace token, sent as a Bearer token. - Settings: Railway project id, Railway service id and Railway environment id. Optional: Health check URL and Watch after a deploy (minutes, default 10).
- Risk: deploy and roll back are both public.
- A deploy sends the newest commit on the main branch when GitHub is set up. Otherwise it deploys the commit linked to the service. It can also redeploy what's live.
- Automatic rollback. With a health check URL, the studio watches each deploy every minute. It rolls back to the deployment that was live before if any of these happens:
- the deploy fails or crashes
- the health check fails twice in a row while it's being watched The rollback is posted in the channel, and the deploy drops one trust level. A deploy still building after 30 minutes stops being watched, and the channel is told.
- Tools:
railway_deploy,railway_rollback,railway_statusto read deployments, andrailway_logsto read the live deployment's recent logs.
Services deployed with railway up
Some services have no GitHub repo linked in Railway. They're deployed with railway up from a checkout on someone's computer. For those, a deploy from the studio can't ship new code: Railway would only rebuild the last upload. So the deploy goes through the business's build agent (like @maya-agent) on its owner's computer instead. Nothing ships without an owner's say-so.
How the studio tells. Before a deploy, it asks Railway whether the service has a repo source (repoTriggers on the service). It remembers the answer for 10 minutes. If the service has one, or Railway doesn't answer, the deploy works as before.
What you approve. Without a repo source, the deploy card names:
- the exact commit: the head of the main branch on GitHub
- the services that deploy
- whose computer runs it
For example: "deploy commit 1a2b3c4 of Pawpost to Railway (web) with railway up on @maya-agent's computer". The deploy runs only after an owner approves the card, or when the trust ladder has "deploy" at spot check or auto for that business.
When it runs:
- The studio checks again that main still points at that commit. If someone merged in the meantime, it stops and asks for a new deploy.
- It gives the build agent a deploy job. The job carries which card (or trust level) approved it, the commit, and the services.
- On the computer, the connector:
- refuses the job if the commit isn't the head of main on GitHub
- puts that exact commit in a deploy worktree of its own,
~/Projects/studio/<slug>-deploy, made withgit worktree addfrom the business's main checkout, detached and clean (never the build agent's "steward" branch worktree) - lets Claude Code run only
railway up --service <name> --project <id> --environment <id> --cifor each service, plusrailway statusandrailway logs, with a fixed prompt: deploy these, report the deployment ids, nothing else
- When the job reports back, the studio finds the new deployments on Railway. From then on, the health watch and automatic rollback work as they do for any deploy. The ledger gets
deploy.handoff,deploy.claimed, and thendeploy.deployed,deploy.refusedordeploy.failed. The product loop treats it like any other deploy.
Which services. One repo can hold more than one service (a business can have a server and a web app). List them under Services deployed with railway up on the Integrations page, as name=<service id>:paths, separated by ;:
server=80dcec7c-...:apps/server,packages; web=1f0e...:apps/web
- The studio picks services from the files the merged pull request changed (GitHub's PR files API).
- A service with no paths always deploys.
- If the studio can't tell which files changed, every service deploys. It does the same when main has moved past the commit you asked for, because the head includes more than that PR.
- If the PR touched none of them (only docs, say), there's nothing to deploy, and the product loop marks the ticket shipped.
- Without the setting, the business's one Railway service id is the service.
What it needs:
- GitHub on for the business, so the studio knows the head of main.
- A build agent that's a personal agent, with its owner an owner of the business. At auto, @steward hands out the job, so the agent must take work from @steward.
- On that computer, the business's repo in the connector config (
repos), a logged-inrailwayCLI, and Claude Code as the engine (deploy jobs refuse other engines, since only Claude Code has an allowlist).
What can't happen:
- A card nobody approved, or a level the ladder isn't at, starts no job.
- Work that read outside text (a support email, a web page) never gets a deploy job. If a deploy job picks up outside text, it gets no deploy scope, and the connector refuses it.
- A commit that isn't the head of main on GitHub is refused, at the card, when it runs, and on the computer.
- The health check URL is the business's one URL, and every service in a deploy is watched against it.
Email (Resend)
Send an email from the business's own address.
- Env:
RESEND_API_KEY, orRESEND_API_KEY_<SLUG>. - Settings: From address, on a domain verified in Resend. Optional: Reply-to address.
- Risk: person. A sent email can't be taken back, so a spot check offers Flag it, not Roll back.
- Each approved email carries an idempotency key, so it never goes out twice.
- Tool:
send_email. Plain text, up to 10 recipients.
Social posts
Post to Bluesky, a Facebook Page and Instagram. X and LinkedIn can be added later as adapters behind the same tool.
- Tool:
social_post, withnetwork(bluesky, facebook, instagram) and an optionalimage_url(a public https link to a JPEG). - Risk: public. Rollback deletes the post, except on Instagram (below).
Bluesky
- Env:
BLUESKY_APP_PASSWORD_<SLUG>. This is an app password from Bluesky's settings, not the account password. It's set per business only. - Settings: Bluesky handle (like
name.bsky.social). - Text only. Links in a post become clickable. A post can be up to 300 characters.
Facebook Page
- Env:
META_PAGE_TOKEN_<SLUG>, a long-lived Page access token withpages_manage_postsandpages_read_engagement. Per business only. - Settings: Facebook Page id.
- Text and links go to
/{page-id}/feed. Withimage_url, a photo and caption go to/{page-id}/photos. Rollback deletes the post.
- An Instagram professional account linked to the Page. It uses the same
META_PAGE_TOKEN_<SLUG>, which also needsinstagram_basicandinstagram_content_publish. - Settings: Instagram account id (the number, not the @name).
- Every post needs
image_url. Captions are up to 2,200 characters. It makes a media container, then publishes it. - Instagram's API can't delete posts. Roll back says so, with the link, so an owner deletes it in the app.
Message people from the business's WhatsApp number (Meta's Cloud API), and take their messages in as support tickets.
- Env:
WHATSAPP_TOKEN_<SLUG>(a system user token withwhatsapp_business_messaging), and for messages inWHATSAPP_APP_SECRET_<SLUG>(the Meta app's secret) andWHATSAPP_VERIFY_TOKEN_<SLUG>(any long random string you choose). Per business only. - Settings: WhatsApp phone number id (from API Setup, not the phone number).
- Risk: person. It starts at suggest and earns trust like email. A sent message can't be taken back.
- Tool:
whatsapp_send, withto(the number with its country code) and either:template:name,language(es,es_CR...) andparams, in order. A first message must be a template WhatsApp approved.text: plain text. Only within 24 hours of the customer's last message.
- Opt-outs. A message that says STOP, BAJA or "no me escriba" opts that number out for good, for this business. Every send checks it again when it runs, so a card approved after the opt-out still doesn't send.
- Messages in. The webhook is
https://<your hub>/api/inbound/whatsapp/<slug>. GET answers Meta's check with the verify token. POST must carry Meta'sX-Hub-Signature-256; anything else gets 401. Messages to another phone number id are skipped. - Each message becomes a support ticket (source whatsapp), the same as inbound email. Its text is outside text to @steward. Replies to a WhatsApp ticket go out by WhatsApp, on their own ladder ("answer a support message on WhatsApp"), and only inside the 24-hour window.
Stripe
Sell, refund, and keep revenue in step with real payments.
- Env:
STRIPE_SECRET_KEYfor the studio, orSTRIPE_SECRET_KEY_<SLUG>for a business with its own Stripe account. A restricted key needs:- write access to products, prices, payment links and refunds
- read access to charges
- Settings: Refunds up to this many dollars may skip the card (default 0).
- Payment links (
stripe_payment_link): a product, a price and a link, one-time or monthly or yearly. Risk money. Rollback turns the link off. - Refunds (
stripe_refund): risk money. Above the owner's refund limit, a refund always waits for an owner's yes, whatever its level. With the default limit of $0, every refund asks. - Revenue sync (
stripe_sync, and every hour on its own): reads the last 30 days of payments. It records each payment's net amount (amount minus refunds) asrevenuein USD. Risk internal.- It's safe to repeat. Each payment is remembered, and only changes are recorded.
- A refund made anywhere shows up as a negative reading on the next sync.
- Payments in other currencies are skipped and counted in the result.
- It runs only while "record revenue from Stripe" is at auto. Set it lower to stop the hourly sync.
STRIPE_SYNC_MINUTESchanges the hour.
- Tool:
stripe_paymentsreads recent payments. - Client billing for service businesses (
service.invoice, risk money): when a client accepts a proposal, a payment link for the setup fee plus the monthly retainer. The engagement is marked paid when the checkout is paid. This needs read access to Checkout Sessions too. See Service businesses.
Paid ads (Reddit Ads, Google Ads)
Campaign drafts with a daily budget cap, pause and resume, budget changes, and spend and clicks every hour, inside an owner-set monthly ad budget the hub enforces in code.
- Reddit env:
REDDIT_ADS_CLIENT_ID,REDDIT_ADS_CLIENT_SECRET,REDDIT_ADS_REFRESH_TOKEN(optionalREDDIT_ADS_USER_AGENT). Settings: Reddit ad account id, optional funding instrument id. - Google env:
GOOGLE_ADS_DEVELOPER_TOKEN,GOOGLE_ADS_CLIENT_ID,GOOGLE_ADS_CLIENT_SECRET,GOOGLE_ADS_REFRESH_TOKEN(optionalGOOGLE_ADS_API_VERSION, default v26). Settings: Google Ads customer id, optional manager account id. - Each also takes a
_<SLUG>version per business. - A new campaign and a budget raise always ask an owner (their ceiling is suggest). Pausing is internal. Meta Ads is listed as coming.
- Tools:
ads_create_campaign,ads_resume,ads_set_budget,ads_pause,ads_status. See Paid ads.
Cloudflare DNS
Read and change the domain's DNS records, and check that a change is live.
- Env:
CLOUDFLARE_API_TOKEN, orCLOUDFLARE_API_TOKEN_<SLUG>. Make it in Cloudflare with Edit zone DNS on the business's domain. - Settings: Cloudflare zone id (32 characters, on the domain's Overview page in Cloudflare).
- Risk: public. Rollback deletes the records it added and puts back the ones it changed.
dns_recordslists records.dns_set_recordsadds or updates A, AAAA, CNAME, MX and TXT records. A record of the same kind at the same name is updated in place: one CNAME per name, and one SPF, DMARC or DKIM record.dns_preset resendsets the records Resend asks for to verify the sending domain. It reads them from Resend's domains API, so it needsRESEND_API_KEYand the domain added in Resend first. It also adds a DMARC policy that only monitors (p=none) if the domain has none.dns_preset railwaypoints a host at a Railway service: a CNAME to the target Railway shows for the custom domain, plus the_railway-verifyTXT record. Both are DNS-only while Railway issues the certificate.dns_verifyasks Cloudflare's and Google's public resolvers whether the records are live. It works without the integration. A checked Resend preset (and Resend saying "verified") marks "Email on the domain" done on the launch checklist.domain_checkchecks whether names are free (RDAP, through rdap.org). Buying a domain is an owner's step; Steward never buys one.
Adding an integration (for builders)
The frame is in src/server/executors/core.ts:
defineIntegration({ id: "x", name: "X", env: (b) => [[envName("X_TOKEN", b), "X_TOKEN"]], settings: ["x_account"], does: "Post on X." });
defineExecutor<{ slug: string; text: string }>({
type: "x.post", integration: "x", risk: "public", label: "post on X",
run: async (ctx, a) => ({ text: "Posted.", undo: { id: "..." } }), // ctx: actor, business, settings
rollback: async (ctx, a, undo) => "Deleted.", // optional
ceiling: (b, a) => null, // optional: cap the level for this call
});
gatedTool(toolDefinition, (args, ctx, business) => ({ type: "x.post", business: business.slug, args: { text: args.text }, summary: "post on X: ..." }));
defineExecutorregisters the executor with the gate (registerExecutor) and the ladder (registerActionMeta). It checks again at run time that the integration is on, since an approval can come days later.gatedToolregisters a @steward tool that checks the room's scope, builds the action and callsgate().readToolis for read-only tools, which skip the gate.http()turns a non-2xx answer into an error that carries the service's own message and never the secrets.- New settings go in
SETTING_FIELDS, with a check. The page draws them. - Import the new file from
src/server/executors/index.ts. - Test with
fetchmocked, likesrc/server/trust.executors.test.ts. Never call the real service from a test.