Docs Running a business
The support loop
The support loop answers a business's inbox. @steward drafts each reply from what the business knows: its charter, its page, its memory and its ledger. The hard ones go to the owners.
Open Support in the sidebar. Only a business's owners see its inbox.
Switching it on
- On Support, pick the business and tick Switched on.
- Set up Email (Resend) on Integrations and trust, so replies can go out.
- Make an inbound secret (Make a secret). It's shown once. Copy it then.
- Forward the business's support email to the inbound address (below), or link to the contact form.
While the loop is off, or the business is paused or over its model budget, tickets still arrive and wait in the inbox. Nothing is drafted. The switch is the same one Releases uses for the other loops.
Where messages come from
- Email:
POST /api/inbound/email/<slug>with the secret, asAuthorization: Bearer <secret>, anX-Steward-Secretheader, or?key=<secret>. The body is JSON:from,to,subject,text,html,message-id, and optionallyin-reply-toandreferences. - Resend inbound: point a Resend
email.receivedwebhook athttps://<your hub>/api/inbound/email/<slug>?key=<secret>. Resend's webhook carries no body, so the hub reads the email from Resend's Receiving API with the business'sRESEND_API_KEY. - The contact form:
/p/<slug>/contact, on the hosted page's domain. It's sandboxed like the page. Link to it from the page. - WhatsApp: Meta's webhook at
/api/inbound/whatsapp/<slug>, signed with the app secret (see WhatsApp). A number's messages thread onto its open WhatsApp ticket. Replies go back by WhatsApp, inside the 24-hour window. Opt-outs (STOP, BAJA) don't become tickets.
Bounces and mail from the business's own address are skipped. A reply threads onto the requester's ticket by In-Reply-To or References, or by the same subject while the ticket is open. A message never joins someone else's ticket.
Forwarding with a Cloudflare Email Worker
Route the support address (like help@yourdomain.com) to a Worker in Cloudflare Email Routing. Set two secrets on the Worker: STEWARD_URL (like https://studio.example.com/api/inbound/email/my-business) and STEWARD_SECRET. It needs the postal-mime package (npm i postal-mime).
// worker.js: forward each email to the studio's support inbox as JSON.
import PostalMime from "postal-mime";
export default {
async email(message, env) {
const raw = await new Response(message.raw).arrayBuffer();
const mail = await PostalMime.parse(raw);
const res = await fetch(env.STEWARD_URL, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${env.STEWARD_SECRET}` },
body: JSON.stringify({
from: mail.from?.name ? `${mail.from.name} <${mail.from.address}>` : mail.from?.address ?? message.from,
to: message.to,
subject: mail.subject ?? "",
text: mail.text ?? "",
html: mail.html ?? "",
"message-id": mail.messageId ?? message.headers.get("message-id") ?? "",
"in-reply-to": mail.inReplyTo ?? message.headers.get("in-reply-to") ?? "",
references: mail.references ?? message.headers.get("references") ?? "",
}),
});
// Ask the sender's server to retry later if the studio didn't take it.
if (res.status >= 500) message.setReject("Temporary failure, try again later.");
},
};
To test it without email: curl -X POST <url> -H "authorization: Bearer <secret>" -H "content-type: application/json" -d '{"from":"you@example.com","subject":"Hi","text":"Do you ship to Canada?"}'.
Tickets
Each ticket has a requester, a subject, the thread, a status (open, waiting on the customer, solved), a priority, a sentiment and tags. A ticket waiting on the customer for 7 days closes itself.
The requester's address is private to the owners. Cards in the channel, the ledger and the agents see a masked form like j***@e***.com. @steward never needs the address: a reply only ever goes to the ticket's own requester.
How a reply goes out
Each new message queues a job for @steward. It drafts a reply and calls support_reply with how sure it is (0 to 1). Then:
- To the owners, as a card with the draft and Send, Edit or Don't reply, when the message is about a refund or cancellation, a legal matter or a threat, the sender is angry or on the VIP list, or the confidence is under the bar (0.7 by default, set on Support). Edit leaves the draft on the Support page to change and send.
- Otherwise through the trust ladder as "answer a support email" (risk person). It starts at suggest: a card with Approve, Change it or Decline. After 5 clean approvals, owners are asked to let it act with a spot check, then alone. Refunds and the rest still come to the owners at any level.
Owners can also write and send a reply on the Support page. That's their own call, so there's no card.
Feedback for the product
Messages tagged feedback, feature-request, bug or idea (or with a feedback line from @steward) go to the product loop's feedback inbox, marked as from support, with the ticket number and never the customer's address. The product loop groups them into tickets with the rest (see Loops). While the product loop is off, the tag on the ticket is all that's kept.
Numbers
On the Support page and in the morning brief: open tickets, cards waiting on you, the median first response time and resolution time, and CSAT. CSAT comes from a "rate this reply" link that @steward adds when a reply should settle the question (/p/<slug>/csat, 1 to 5).
Env
RESEND_API_KEYorRESEND_API_KEY_<SLUG>: sending replies, and reading Resend's inbound emails.PUBLIC_URL: the hub's address, for the inbound address and the CSAT link.SUPPORT_INBOUND_PER_HOUR(default 120): inbound emails per business per hour.
Tools
support_reply, support_update and support_tickets.