@episterra/epigo-sdk
The EpiGo platform SDK for Episterra-built sites. A site's server holds a connection that a workspace owner or admin approved once; through it the SDK calls go-ai tools (typed, under the connection's tool grants, approvals and audit) and reads the database as that admin (typed, read-only). Visitor-facing pages use the public half, which needs only the public anon key.
Design: docs/platform-sdk.md in the go-ai repository. Connecting a site: docs/platform-sdk-site-connections.md.
Server
import { createEpigoSite } from "@episterra/epigo-sdk";
const site = await createEpigoSite({
credentials: {
clientId: process.env.EPIGO_CLIENT_ID!,
clientSecret: process.env.EPIGO_CLIENT_SECRET!,
refreshToken: await store.get("epigo_refresh_token"),
// Refresh tokens rotate: persist every new pair, or a restart is cut off.
onTokens: ({ refreshToken }) => store.set("epigo_refresh_token", refreshToken),
},
});
// Actions: typed by the generated catalog, run under the connection's grants.
const r = await site.tools.call("create_lead", { name: "Bigsby", email: "[email protected]" });
if (r.pendingApproval) {
// A workspace admin decides it in the app.
}
// Reads: supabase-js, typed like the app, as the approving admin, read-only.
const { data } = await site.db.from("store_orders").select("id, status, total").eq("buyer_email", email);
// An RPC read must be a GET: the database refuses a site's writes, and supabase-js posts an RPC by default.
const { data: rows } = await site.db.rpc("some_reader", { p_workspace_id }, { get: true });
// Money, the way the app prints it.
site.money.formatCents(88750); // "$887.50"
createEpigoSite refuses to run in a browser; the platform refuses the same requests by their Origin header. Keep the client secret and the tokens on the server, filter every portal read to the signed-in customer on the server, and sign customers in with a verified email.
Visitor pages
import { createEpigoPublic, loadConfig } from "@episterra/epigo-sdk";
const pub = createEpigoPublic(await loadConfig());
await pub.store.catalog({ ws_slug: "bigsbys" });
await pub.concierge.turn({ form_id, step_id, visitor_key, messages: [{ role: "user", content: text }] }); // the whole conversation so far
await pub.forms.submit({ form_id, data: answers });
In a browser, forms.submit and the concierge also send the page's address (page_url) and the website analytics tag's ids (analytics: { session_id, visitor_id }, from sessionStorage __va_sid and localStorage __va_vid), so the request EpiGo opens is linked to the visit (0.10.1). A body that carries its own page_url or analytics is sent as it is.
Endpoints and the anon key come from GET https://api.epigo.ai/functions/v1/sdk-config at start-up, so a key rotation never needs a republish.
Staff sign-in
A site's staff sign in with their own EpiGo accounts on EpiGo's hosted login page, and every read and call then goes out with that person's own access, as in the EpiGo app. The site needs its app client id from Episterra; it holds no secret. Browser only.
import { createEpigoUser } from "@episterra/epigo-sdk";
const staff = await createEpigoUser({
clientId: "<the site's app client id>",
redirectUri: `${location.origin}/auth/callback`, // registered with the client
});
// Sign-in button:
await staff.signIn({ returnTo: "/staff" });
// On /auth/callback:
const { returnTo } = await staff.handleCallback();
location.replace(returnTo ?? "/staff");
// Anywhere after:
staff.isSignedIn();
staff.claims(); // { sub, email, emailVerified }
await staff.db.from("cal_entries").select("id, title, starts_at");
await staff.functions.invoke("some-function", { workspace_id });
await staff.signOut();
The hosted login's address comes from sdk-config (sign_in); an environment without one answers null and signIn throws. The code is exchanged with PKCE, the access token is renewed a minute before it expires, and a refused refresh signs the person out.
Sign in without leaving the page (0.4.0)
signIn({ popup: true }) opens the hosted login in a small window over the page and resolves once the person is signed in; nothing navigates. Call it straight from the click handler (a browser allows a window only during the click). A blocked window, or a browser without BroadcastChannel, falls back to the full-page sign-in above.
// Sign-in button:
signInButton.onclick = async () => {
await staff.signIn({ popup: true });
render(staff.claims()); // signed in, still on this page
};
// On /auth/callback, the same page serves both: the window finishes the sign-in, tells the page and closes.
const { returnTo } = await staff.handleCallback();
location.replace(returnTo ?? "/staff"); // reached only when the browser kept the window open
The window finishes the sign-in itself on the callback page, because EpiGo's page cuts it off from the page that opened it (it cannot be framed either). staff.isPopupCallback() tells a callback page that does other work (a preview relay, say) that this callback is a popup's, for handleCallback. If the person closes the window, signIn rejects after timeoutMs (10 minutes by default); a second click simply reopens the window.
Customer sign-in (the customer portal)
A site's customers sign in on the customer pool's hosted login with a code emailed to them, then read the database's portal views: their own orders, quotes, invoices and events in the site's workspace, filtered by the database to the customer accounts their verified email belongs to. The customer pool has its own app client per site. Browser only.
import { createEpigoUser } from "@episterra/epigo-sdk";
const customer = await createEpigoUser({
mode: "customer",
clientId: "<the site's customer app client id>",
redirectUri: `${location.origin}/account/callback`, // registered with the customer client
});
await customer.signIn({ returnTo: "/account" });
// On /account/callback:
const { returnTo } = await customer.handleCallback();
// The views: orders, order_lines, quotes, quote_lines, invoices, events (typed: PortalOrder, PortalInvoice, …).
const { data: orders } = await customer.portal.from("orders").select("*").order("placed_at", { ascending: false });
const { data: invoices } = await customer.portal.from("invoices").select("*");
// An invoice's stripe_payment_link_url opens the hosted page that pays its balance.
// Files staff chose to show on the customer's orders ("Customer can see" on the order's Files tab).
const { data: files } = await customer.portal.from("order_files").select("*").eq("order_id", orders![0].id);
const link = await customer.openPortalFile(files![0].id); // { url, name, mime_type, expires_in }: a link for 5 minutes
window.open(link.url, "_blank", "noopener"); // or { download: true } for a download named after the file
await customer.signOut();
A booking-portal page signed in with a booking pass (not the customer pool) reads the same order_files view with its pass, and opens a file by posting { pass, file_id, download? } to the portal-file function with the anon key.
Money and guests come computed (0.7.0)
Show the database's numbers; never add payments, invoices or quantities up on the page.
const { data: order } = await customer.portal.from("orders").select("total, paid_total, balance_due").eq("id", orderId).single();
// paid_total: paid, net of refunds, as go-ai counts it (a payment naming the order, its invoices or its quote).
// balance_due: what is still owed, never below 0; null when go-ai states no balance (a cancelled or refunded
// order, or one settled outside EpiGo's ledger). Show "Paid in full" for 0, nothing for null.
const { data: lines } = await customer.portal.from("order_lines").select("id, label, qty, guests, parent_line_id").eq("order_id", orderId);
// guests: the head count the line's calculator priced it by, or null. Never a line's qty.
Summing portal.invoices gives a different answer: an imported booking's payments are on no invoice. Staff pages read private_event_money(workspace_id) (each private-event order's value and paid) through staff.db.rpc instead of summing in the browser.
portal sends Accept-Profile: portal, the only profile the database lets a customer token read; db throws in customer mode. Customer functions.invoke calls go with the anon key, because the function tier accepts only staff tokens. The login's address comes from sdk-config (customer_sign_in).
Staff with no EpiGo login: a work pass (0.9.0)
Hourly staff often have no EpiGo login. createEpigoWorkPass signs them in on a staff page of the business's own site with a link EpiGo emails to their WORK EMAIL, through the same door as EpiGo's own staff page (/staff/<slug>): their shifts, open shifts to pick up, swaps, time off and requests, their hours and pay, and their conversations with coworkers and managers. Browser only; it holds no secret.
go-ai lists each workspace's staff page (STAFF_PAGES_BY_API in supabase/functions/_shared/work-portal-link.ts), so every link, notice and push EpiGo sends that workspace's staff opens the site's page only once go-ai lists it there. Until then they open EpiGo's page. Adding a site's staff page is a PR, like adding its sign-in client, and it goes in once the site's page signs people in, so no link reaches a page that cannot (Bigsby's Cellar, https://www.bigsbysfolly.com/staff/me, is next).
import { createEpigoWorkPass, EpigoWorkPassError } from "@episterra/epigo-sdk";
import { workWarningText } from "@episterra/epigo-sdk/rules";
const crew = await createEpigoWorkPass({ workspace: "bigsbys-folly", serviceWorkerUrl: "/sw.js" });
// On every load of the staff page: keeps a link from #access=..., clears it from the address bar, opens it.
const signedIn = await crew.signIn();
if (signedIn.state !== "signed_in") {
// "signed_out" (no link here yet: message null), "expired", "invalid", "member" (they have a login now), "retry".
// Show "Email me a link" with signedIn.message.
const asked = await crew.requestLink(email); // reads the same whether or not EpiGo knows the address
show(asked.message);
} else {
const { me, chatId } = signedIn; // chatId: the conversation a message's notice opened
const schedule = await crew.schedule(); // work, open_slots, offers, time_off, requests, policy, hours, announcements
const time = await crew.time(); // entries, periods (closed pay weeks), totals
schedule.open_slots.forEach((s) => s.warnings.map(workWarningText)); // what stands in the way, in EpiGo's words
try {
await crew.request({ kind: "pickup", taskId: slot.task_id, slotId: slot.slot_id! });
await crew.request({ kind: "time_off", firstDay: "2026-11-10", lastDay: "2026-11-12", note: "Family visit" });
const people = await crew.swapCandidates(shift.task_id, shift.slot_id!); // offer only can_answer ones
await crew.answer(offer.id, true); // a swap offered to them
} catch (e) {
if (e instanceof EpigoWorkPassError) show(e.message); // EpiGo's sentence; state "signed_out" means ask for a new link
}
const { chats, unread } = await crew.chat.list();
const thread = await crew.chat.thread(chats[0]!.id); // { chat, messages, has_more }; older: { before: messages[0].at }
await crew.chat.send(thread.chat!.id, "On my way");
await crew.chat.read(thread.chat!.id);
}
- The pass. A link works for 7 days, as often as needed. Opening it answers a pass for an hour; the client opens the link again five minutes before the pass ends and whenever a read or an action finds it gone, so a revoked link, a login given or a changed work email ends the session within the hour. A link that stops working is forgotten on the device.
- Refusals. The workspace's own request policy and rules decide every action. A refusal throws
EpigoWorkPassErrorwithstate(refused,invalid,signed_out,retry) and the sentence to show. - Notifications.
crew.push.enable(), called straight from a click, asks the browser, subscribes it through the site's service worker with the keysdk-configpublishes (config.push), and keeps it against the person;crew.push.disable()andcrew.signOut()end it. The worker shows each push, JSON{ title, body, url, tag }, and opensurlon a click (a message's carries#chat=<conversation>). On an iPhone, push works only once the site is added to the home screen. - The time clock (0.10.0), only where the workspace turns it on. Clocking in from a phone is a workspace setting, off by default; while it is off people clock in and out at the workplace's time clock. See below.
The time clock (0.10.0)
me.clock.phone says whether the workspace lets people clock in from a phone: off (the default), location or network. Show a clock only when workClockOn(me) says yes. Every punch goes through the same door as EpiGo's own staff page, as the pass's person, so the workspace's clock rules apply as at its time clock: when a clock-in opens, grace, rounding, the location rule, the network rule and the break sign-off.
import { workClockOn, workClockWantsLocation } from "@episterra/epigo-sdk";
if (workClockOn(me)) {
const clock = await crew.clock.state(); // open, on_break, shift_now, shift_next, can_clock_in, policy, timezone
clock.shift_now?.rule_warnings?.map(workWarningText); // shown, never a refusal
async function punch(p: "clock_in" | "clock_out" | "break_start" | "break_end") {
// Ask for the position from the punch's click, and only where the location rule wants it.
const location = workClockWantsLocation(p, clock.policy) ? await crew.clock.position() : null;
try {
const { entry, clock: now } = await crew.clock.punch(p, {
location, // clock in and clock out only
attestation: p === "clock_out" && clock.policy.break_attestation ? { breaksTaken, note } : null,
breakKind: p === "break_start" ? kind : undefined, // "rest" (paid) or "meal" (unpaid, what EpiGo takes when none is said)
});
show(now); // the clock as it now stands
} catch (e) {
if (e instanceof EpigoWorkPassError) show(e.message); // the clock's own sentence, word for word
}
}
}
- Off means no clock. While the setting is off every punch is refused, word for word, "Clock in and out at your workplace's time clock.", and nothing is written. A page shows no clock then and reads no clock state.
- Refusals are the clock's own sentences: no shift open for clock-in, already clocked in, not on the workplace's Wi-Fi, the location the rules require, the break sign-off missing. Show them as they come.
- Position.
crew.clock.position()asks the device once (the browser's prompt the first time):{ lat, lng, accuracyM }, ornullwhen the browser has none, the person says no, or no fix comes in time (10 seconds). It never throws. Underlocation: "record"a punch with no position is flagged; under"require"a clock-in without one is refused. - From the person's own browser. Punch from the staff page itself, never from the site's server: under the network rule the door checks the address the punch comes from.
Rules (0.6.0)
@episterra/epigo-sdk/rules is go-ai's own rule code, the same files the app and its edge functions run, copied at each release. A page imports the rule instead of writing its own, so it agrees with the server, and a change to the rule arrives with the next version. It imports nothing else from the SDK and bundles no supabase-js, so it runs in any browser. Design: docs/platform-builder-kit.md (part 1).
import { portalMenuRules } from "@episterra/epigo-sdk/rules";
// The booking portal's menu answer (`menu` on booking-portal-session) carries `rules`: the groups and options behind
// its sections, with only the fields the rules read.
const r = portalMenuRules(menu);
r.limits(section.group_id); // { min: 2, max: 2, pickOne: false }: "choose 2", without parsing the hint
r.prune(picks); // a changed choice takes what it opened with it
r.complete(picks); // "Select" alone brings its Bar package along
r.check(picks)?.message; // "Proteins: choose 2.", as the server says it
r.startDetail(optionId); // { qty: 2 }: where a new pick starts (drink tickets, 2 a guest)
| Rule | Exports |
|---|---|
| Option groups: pick limits, show-when, a choice and what it opens, the first problem | groupPickLimits, pickLimitsHint, activeGroupIds, showWhenChildren, completeShowWhenChain, checkOptionRules |
| Picks: quantity, bounds, starting quantity, sizes | calcPickRules, defaultPickDetail |
| A document's lines: functions and their options, the $0 heading, guests, the caption | groupFunctionLines, headsItsOptions, functionGuests, functionCaption |
| Booking stages: the eight words, their labels, which rows are Bookings | BOOKING_STAGE_WORDS, BOOKING_STAGE_LABELS, stageWordOf, stageLabel, isBookingsRow |
| Change states: the ten states and the words the guest and the team read (0.8.0) | CHANGE_STATES, CUSTOMER_STATE_WORDS, customerStateWords, TEAM_STATE_WORDS |
| Texting consent: the sentence the server keeps, word for word, and which one a page shows (0.8.3) | textsConsentWording, textsConsentWordingChangesOnly, textsConsentScope, phoneWords, toE164 |
A staff page that reads finance_option_groups and finance_options itself calls the option functions on the rows it read.
Texts about the booking cover the team's messages (0.8.3)
textsConsentWording(venue, phone) now gives the sentence "I agree to receive text messages from {venue} at {phone} about this booking, including messages from its team and changes to the booking. Message frequency varies. Message and data rates may apply. Reply STOP to opt out." A guest who agrees to it (sms_consent with that sentence as wording) is texted each message the team sends them in the booking's conversation, from the venue's own number, with a link to the portal (never its access token), as well as the texts about changes. Before a number is typed the sentence reads "at my mobile number". A portal shows the new sentence, or the wording the texts read answers, and may put its texts box wherever it likes, such as beside the booking's messages.
texts.covers_messages, in thetextsandsms_consentanswers, says whether the yes that stands covers the team's messages.textsConsentWordingChangesOnly(venue, phone)is the sentence before 0.8.3, word for word. go-ai still keeps a yes given under it, as texts about changes only, so a page on 0.8.2 keeps working until it updates; such a guest gets the team's messages by text only once they agree to the new sentence.textsConsentScope(shown, venue, phone)answers"booking"for the new sentence,"changes"for the old one andnullfor any other words, which go-ai refuses asstale.
The builder guide (0.8.0)
skills/epigo-builder/SKILL.md in this package is the guide an AI site builder (Lovable, Claude) reads before it builds on EpiGo: which client each page uses, the call for each step of a request, hold, booking, quote, change or payment, every booking-portal action with its states, and the numbers and rules a page must never work out itself. It is generated from go-ai at each release, and go-ai's tests fail when it stops matching the platform, so a site learns a change from the next version's guide instead of from a chat message. Each version adds a "What changed for builders" note.
- Lovable: the Episterra team installs it as a workspace skill named
epigo-builderin each Lovable workspace that builds on EpiGo, where every project gets it, and updates it with each release. - Claude: copy
node_modules/@episterra/epigo-sdk/skills/epigo-builder/into the site repository's.claude/skills/. - Any agent connected to go-ai's MCP server (
https://api.epigo.ai/functions/v1/mcp-server): callget_sdk_guide. It returns the install steps and this guide for the release go-ai runs, on every connection.
Requirements
Node 22 or newer on the server (supabase-js needs the runtime's native WebSocket). Deno and Bun work too. createEpigoUser and createEpigoWorkPass run in the browser.
Versions
The package is generated from one platform commit (epigo.platformCommit in package.json of a release). Pin a version and upgrade deliberately; breaking changes are allowed between releases, and the Episterra team updates the affected sites when it releases.