# Actions Source: https://docs.pult.sh/build/actions What your team can do to a record, with the guardrails written next to it. Actions are the verbs of a resource. Each one runs inside your app when someone in your team (or an agent, or a triage rule) runs it from Pult. Pult never touches your database; it asks, and your code decides. ```ts theme={null} export const user = resource("user", { get: (id) => db.users.find(id), title: (u) => u.username, actions: (action) => ({ ban: action({ input: z.object({ reason: z.string().min(3).describe("Shown to the user"), days: z.number().int().positive().optional() .describe("Leave empty for a permanent ban"), }), when: (u) => u.bannedAt === null, danger: "reauth", approval: true, run: ({ record, input, actor }) => banUser(record.id, { ...input, by: actor.name }), }), unban: action({ when: (u) => u.bannedAt !== null, run: ({ record }) => unbanUser(record.id), }), }), }) ``` The key (`ban`) is the action's identity. It's referred to elsewhere as `user.actions.ban`. ## Running `run` receives: * `record`: the record, freshly fetched with `get`. * `input`: the form values, validated against `input` and typed from it. * `actor`: who ran it, with `name`, `type` (`user`, `agent` or `triage`) and their role. * `item`: the id of the item it was run from, if any. Return nothing, a message, or a message with blocks: ```ts theme={null} run: ({ record }) => `Refunded ${formatMoney(record.total)}`, run: async ({ record }) => ({ message: "Invoice sent", blocks: [b.attachment({ name: "invoice.pdf", url })], }), ``` The result shows in a toast and in the item's or record's history. Throw an error to fail: its message is shown to the person and recorded too. Actions always wait for your app. The console says it's waiting for your app to confirm, and if your app is offline the action is queued and marked as waiting until your app answers. ## Forms `input` is a schema, and Pult builds the form from it: text fields, numbers, dates, switches, single and multiple choice from enums, and multi-line boxes for strings longer than 200 characters. `.describe()` adds a hint under a field. An action without `input` runs straight away (unless it needs confirmation). `defaults(record)` prefills the form from the record: ```ts theme={null} setSeats: action({ input: z.object({ seats: z.number().int().min(1) }), defaults: (org) => ({ seats: org.seats }), run: ({ record, input }) => db.orgs.setSeats(record.id, input.seats), }), ``` `pick` turns a field into a searchable record picker, and `choices` fills a dropdown from your app: ```ts theme={null} transfer: action({ input: z.object({ owner: z.string() }), pick: { owner: "user" }, run: ({ record, input }) => db.repos.transfer(record.id, input.owner), }), changePlan: action({ input: z.object({ plan: z.string() }), choices: { plan: (org) => billing.plansFor(org) }, run: ({ record, input }) => billing.change(record.id, input.plan), }), ``` Choices can be strings or `{ value, label }` pairs. The history shows a picked record by its title. ## When an action applies `when(record)` hides the action unless it applies right now: Ban only for users who aren't banned, Unban only for those who are. Your app checks it again when the action runs, so an action that stopped applying in the meantime is refused with a clear message. ## Guardrails `danger: "confirm"` asks for confirmation first. `danger: "reauth"` also asks the person to sign in again if they haven't in the last ten minutes. Dangerous actions are shown in red. `approval: true` means one person asks and someone else approves. The request waits under Approvals; the person who asked can't approve it themselves, and approving a `reauth` action needs a fresh sign-in too. The history shows who asked, who approved and what ran. `preview` shows what would happen before anyone commits: ```ts theme={null} preview: ({ record }) => [ b.markdown(`**${record.username}** loses ${countRepos(record)} repos`), ], ``` ## Background actions Long work (exports, migrations, bulk changes in your own system) can run in the background: ```ts theme={null} exportActivity: action({ background: true, run: async ({ record, progress }) => { for (const [index, month] of months.entries()) { await exportMonth(record.id, month) await progress((index + 1) / months.length, `${month} done`) } const csv = { name: "activity.csv", contentType: "text/csv", data } return { message: "Export ready", blocks: [b.attachment(csv)] } }, }), ``` The console doesn't wait. The history shows a progress bar with your notes, then the result. `progress` takes a fraction between 0 and 1 (or `null` when you don't know) and an optional note. ## Actions without a record Some work isn't about one record: purging a cache, re-running last night's payouts, sending a digest. Define those on their own: ```ts theme={null} import { action } from "pult" export const purgeCache = action("purge-cache", { label: "Purge CDN cache", input: z.object({ path: z.string().default("/") }), danger: "confirm", run: ({ input, actor }) => cdn.purge(input.path, { by: actor.name }), }) ``` They take the same options as any action except `when`, and `record` is `null` when they run. Grant them in a role like other actions. People run them from ⌘K, from [pages](/build/pages) and from any block with `b.actions(purgeCache)`. ## On a schedule An action without a record can also run by itself: ```ts theme={null} export const weeklyDigest = action("weekly-digest", { schedule: { cron: "0 9 * * 1", timezone: "Europe/Amsterdam" }, run: () => sendDigest(), }) ``` `schedule` is a cron expression, or an object with one and a timezone. Without a timezone it runs in UTC. Each environment runs its own schedule, and every run is in the audit log under "Schedule" with its result. If your app can't be reached when a run is due, that run is recorded as failed instead of piling up for later. People can still run a scheduled action by hand. `pult check` makes sure scheduled actions need no input without a default and no approval, since nobody is there to give them. ## Triage and agents Triage rules can run actions that are harmless (`danger: "none"`) or that you mark `automatable: true`. They never run actions that need an approval. Agents run actions through their role like people do, but can't run `reauth` actions, since they can't sign in again. ## Running on many at once People can run an action on many records from a resource's table, or on the records of many items from a list. Each one runs separately in your app and the result says how many succeeded. # Blocks Source: https://docs.pult.sh/build/blocks The pieces items, records, pages and action results are made of. Everything your app shows in Pult is a list of blocks: an item's content, a record's page, a [page](/build/pages), the result of an action. Every block works in all of them, so a button, a flag or a live count can sit on a user's page as easily as on a dashboard. Build them with `b`: ```ts theme={null} import { b } from "pult" render: (item) => [ b.markdown(`**${item.data.reporter}** reported this comment`), b.fields({ author: b.ref("user", item.data.authorId, item.data.author), reports: item.occurrences, }), b.code(item.data.stack, { lang: "trace" }), ] ``` ## Text `b.markdown(text)` renders Markdown. Raw HTML isn't allowed. `b.code(code, { lang, title })` shows code or logs with a copy button. `lang: "trace"` highlights the first line of a stack trace and file locations; `lang: "log"` highlights file locations. ## Fields `b.fields({ label: value })` shows labelled values. A value can be a string, a number, a boolean, a `Date`, or one of these: * `b.ref(resource, id, title)` links to a record. Items link to every record mentioned in their fields, and offer that record's actions. * `b.sensitive(value, { hint })` hides a value until someone allowed to reveals it. See [Sensitive values](#sensitive-values). * `item.occurrences`, `item.users`, `item.firstSeen`, `item.lastSeen` and `item.due` inside `render` are live: they stay current as the item changes. `undefined` values are left out, so optional fields need no special handling. `b.stats({ label: value })` shows the same values as large numbers, for the top of a page. A stat can carry a note: `{ value: 41, note: "of 120 seats" }`. ## Editing in place `b.edit(value, action)` shows a value with a pencil next to it. Clicking it opens the action's form with the value filled in: ```ts theme={null} b.fields({ plan: b.edit(u.plan, "changePlan") }) ``` The value goes into the action's only input field, or the one you name with `{ field }`. Everything about the action still applies: who may run it, its confirmation and its approval. ## Records and tables `b.record(resource, id, title)` shows a record as a link on its own line. `b.table(rows, columns)` shows a table. Rows are objects; cells take the same values as fields, including `b.ref` and `b.sensitive`. Columns default to the keys of the first row. ```ts theme={null} b.table( org.members.map((m) => ({ member: b.ref("user", m.id, m.username), plan: m.plan, joined: m.createdAt, })), ) ``` ## Charts and timelines `b.chart(points, { title })` charts your own numbers, given as `{ at, value }` pairs. `b.chart("occurrences", { range })` charts how often an item happened over `"1h"`, `"24h"`, `"7d"` or `"30d"`, and `b.chart(inbox, { range })` does the same for everything in an inbox. Pult keeps those numbers; your app only places the chart. `b.timeline(entries)` shows a list of `{ at, text }` events. ## Buttons `b.actions(...)` shows buttons that run actions: ```ts theme={null} b.actions( purgeCache, { action: user.actions.ban, record: u.id, label: "Ban author" }, { action: exportOrders, input: { since: "2026-01-01" } }, ) ``` Pass an action as it is, or with the `record` it runs on, a `label` and `input` to start the form with. Buttons for actions the viewer can't run are left out. On records and pages, so are buttons whose action doesn't apply to its record right now (its `when` says no), and actions with `defaults` start from them. The same goes for `b.edit`. Items keep their buttons as your app sent them. Pressing one works like anywhere else in the console: it runs straight away, or opens the form when the action has inputs or guardrails. `b.form(action)` shows an action's form right on the page instead of behind a button, for the things people do there all day, like broadcasting a message. It takes the same `record`, `label` and `input` as a button. Inside a resource's `render`, refer to the resource's own actions by name, since the resource isn't defined yet while it renders. They run on the record being shown: ```ts theme={null} render: (u) => [b.actions("restrict", "ban")], ``` ## From Pult These blocks show Pult's own data, worked out when someone looks, so they're always current and only show what the viewer's role allows: * `b.count(inbox, { filter, label })` and `b.count(view)` show how many items match, linking to the list. * `b.items(inbox, { filter, limit, label })` and `b.items(view)` show the latest few items. * `b.flag(flag)` shows a [flag](/build/flags) and its value, with the switch for people allowed to change it. ## Layout `b.columns([...], [...])` puts lists of blocks side by side, as many columns as you give it. On a phone they stack. ## Attachments `b.attachment({ name, url })` links to a file your app hosts. To hand Pult the file itself, pass its contents and Pult stores it: ```ts theme={null} b.attachment({ name: "export.csv", contentType: "text/csv", data: bytes }) ``` Files are uploaded when the block is sent, up to 20 MB each, and only people signed in to that environment can open them. ## Sensitive values ```ts theme={null} const [name, domain] = user.email.split("@") const hint = `${name[0]}…@${domain}` b.fields({ email: b.sensitive(user.email, { hint }) }) ``` The value never reaches the browser until someone reveals it. People see the hint, or "Hidden" without one. Admins and roles with `reveal: true` can reveal it on items, occurrences, records and pages, and every reveal is recorded on the item or record it came from and in the audit log. In tables of records, action results and history entries, sensitive values stay hidden for everyone. Triage never sees them. # Email Source: https://docs.pult.sh/build/email Reply to customers from your own domain and thread their answers back onto the item. Pult never sends mail as your domain. Replies written in the console are sent by your app, with whatever provider you already use, and replies from customers come back through your app too. ## Sending Give the inbox an address to reply to and a way to send: ```ts theme={null} export const support = inbox("support", { data: z.object({ email: z.email(), subject: z.string(), body: z.string().max(5000), }), title: (data) => data.subject, reply: { to: (item) => item.data.email, send: ({ to, subject, text, thread }) => resend.emails.send({ from: "support@acme.com", replyTo: `support+${thread}@acme.com`, to, subject, text, }), }, }) ``` Items in this inbox get an Email tab next to notes. `send` runs inside your app and receives `to`, `subject`, `text`, `thread`, the `item` (with its data) and the `actor` who wrote it. If your app is offline, the reply waits and the timeline says so until it goes out. ## Threading replies `thread` identifies the item's conversation. Put it somewhere a reply carries back: a plus address like above, or a header your provider keeps. When your provider's inbound webhook reaches your app, hand the message to Pult: ```ts theme={null} app.post("/webhooks/inbound-email", async (c) => { const { to, from, subject, text } = await c.req.json() const thread = /\+([a-z0-9]+)@/.exec(to)?.[1] const received = thread && (await pult.receive({ thread, from, subject, text })) if (!received) { await pult.send(support, { data: { email: from, subject, body: text } }) } return c.body(null, 204) }) ``` The message joins the item's timeline. If the item was closed it reopens, and whoever it's assigned to is emailed. `receive` returns `null` for a thread Pult doesn't know, so a new conversation becomes whatever you decide; above, a new support item. ## Pult's own email Notifications to your team come from Pult's own address. They're the only mail Pult sends itself. # Flags Source: https://docs.pult.sh/build/flags Feature flags, kill switches and settings, typed in code and changed by your team. A flag is a value your app reads and your team changes. Turn a feature on for some users, stop something that's misbehaving, or tune a limit without a deploy. ```ts theme={null} import { flag } from "pult" import { z } from "zod" export const newEditor = flag("new-editor", { description: "The rewritten code editor.", target: user, attributes: z.object({ plan: z.enum(["free", "pro", "team"]) }), guard: { inbox: crashes, perHour: 20 }, }) export const signupLimit = flag("signup-limit", { label: "Daily signups", value: z.number().int().min(0), default: 500, approval: true, }) ``` Without `value`, a flag is on or off and starts off. With a schema, it holds any value the schema accepts, starting at `default`, and `pult build` checks the default is valid. ## Reading a flag ```ts theme={null} if (await pult.flag(newEditor, { key: user.id, plan: user.plan })) { showNewEditor() } const limit = await pult.flag(signupLimit) ``` The type comes from the flag, and so do the attributes you can pass. Pass a key, usually the id of the user or account the answer is for, or just the key as a string, and Pult picks the value in this order: 1. A value set for that record, if the flag has a `target`. 2. The first [rule](#rules) whose conditions match the attributes you passed. 3. A rollout, for on and off flags: a share of keys that get it, always the same ones, so raising the share only adds people and lowering it only takes away the most recent. 4. The environment's value. 5. The flag's `default`, if nothing's been set or the stored value no longer fits the schema. Rollouts are decided per key. Code that reads an on and off flag without a key sees it on only once its rollout reaches everyone, so a rollout never switches everybody at once. Flags are read in your app, from a copy the SDK keeps. With `pult.listen()`, changes arrive over the connection within a moment. Without it, on serverless platforms, the SDK checks at most every ten seconds, and the check costs almost nothing when nothing changed. If Pult can't be reached, your app keeps the last values it saw, or the defaults. ## Rules `attributes` describes what your app can tell Pult about whoever's asking: their plan, their country, how many seats they have. Your team then writes rules in the console, like "plan is pro or team: on" or "seats above 50: on for 20%". Rules are checked in order and the first match wins. A condition on an attribute your app didn't pass doesn't match, except "is not". Pult never sees these attributes. They're compared in your app, against rules it already has. ## Changing gradually Any on and off flag, and any flag holding a number, can move to a new value over time instead of all at once. Pick where it should end up and how: * **Over a period**: "to 100% over 5 days", moving smoothly or in steps, like once a day. * **Step by step**: "10% more every hour" until it gets there. It works in both directions. Turn maintenance on for everyone, then let people back a tenth at a time; raise a signup limit from 500 to 2,000 over a week. The console shows how it'll move before you save, and where it is while it runs. Stop it at any point and it stays where it got to. A change can also start later, or happen all at once at a set time: turn maintenance on at 2 on Sunday morning, before anyone's awake to do it. ## Guards `guard` ties a flag to an inbox. While the flag is moving, if that inbox gets `perHour` items or occurrences within an hour, Pult stops the ramp where it is, or with `then: "revert"` puts it back where it started. The audit log says what happened and why. With a guard on your crashes inbox, a rollout that starts breaking things stops itself before it reaches everyone. Your app works out where a ramp is from the clock, so nothing has to reach it at each step. A ramp that needs approval starts once it's approved. ## In the console Flags have their own page in the sidebar. On and off flags have a switch; others show their value with a button to change it. More opens rollouts, gradual changes and values for specific records, picked by search like any record. `danger` and `approval` work as they do for [actions](/build/actions#guardrails): a confirmation, a fresh sign-in, or a second person before the change happens. Every change is in the audit log with its before and after. Each environment has its own values. Changing a flag in staging doesn't touch production. Every flag shows how often your app read it and when it last did, so flags nobody reads any more are easy to spot and remove. To put a flag on a [page](/build/pages) or a record, use `b.flag(newEditor)`. ## Who can change a flag Everyone can see flags and their values. Changing one is granted in [roles](/build/roles) with `flags`, and admins can change all of them. # Hooks and history Source: https://docs.pult.sh/build/hooks React in your app when an item changes state, and add your app's own events to a record's history. ## State hooks `onState` on an inbox runs in your app whenever an item changes state, whether a person, a triage rule or a new occurrence moved it: ```ts theme={null} export const pilots = inbox("pilots", { states: ["requested", "talking", "active", "converted", "dead"], closed: ["converted", "dead"], data: z.object({ company: z.string(), seats: z.number() }), onState: async ({ item, to }) => { if (to !== "converted") return await provisionSeats(item.data.company, item.data.seats) }, }) ``` It receives the item (`id`, `title`, `record` and its typed `data`), the state it left (`from`), the state it's in now (`to`) and who moved it (`actor`). If the hook throws, the failure shows in the item's timeline; the state change itself stands. ## Record history A record's page shows what was done to it in Pult. Your app can add its own events, so the person about to act sees what happened on your side too: ```ts theme={null} await pult.log(user, user.id, "Changed plan to Pro", { by: user.username }) await pult.log(order, order.id, "Payment failed", { blocks: [b.fields({ reason: error.code })], }) ``` `by` says who did it in your app; without it the entry is from your app. Entries can carry [blocks](/build/blocks), and show up live on the record's page. # Inboxes Source: https://docs.pult.sh/build/inboxes States, data, rendering, layouts, response times and public forms. An inbox is where one kind of work lands: crash reports, abuse reports, support requests, trial sign-ups. It owns the shape of what your app sends and how each item looks. ```ts theme={null} import { b, inbox } from "pult" import { z } from "zod" import { message } from "../src/messages/message.pult" export const reports = inbox("reports", { icon: "flag", resource: message, states: ["open", "actioned", "dismissed"], reopenOn: "new-occurrence", sla: { open: "1h" }, data: z.object({ reason: z.enum(["spam", "harassment", "other"]), reporter: z.string(), body: z.string().max(5000), }), render: (item) => [ b.markdown(`> ${item.data.body}`), b.fields({ reason: item.data.reason, reports: item.occurrences, "last reported": item.lastSeen, }), ], }) ``` The name (`"reports"`) is the inbox's identity. Rename it and Pult treats it as a new inbox; the old one is archived with its items intact. ## States `states` lists the states an item moves through, first one first. `closed` says which of them count as done. If you give `states` but not `closed`, every state after the first is closed. Without `states`, an inbox uses `open`, `in progress` and `handled`, with `handled` closed. Closed items leave the open list and the sidebar count. In the console, `e` moves an item to the first closed state and `E` reopens it. ## Reopening `reopenOn: "new-occurrence"` reopens a closed item when your app sends the same fingerprint again, tags it `regressed` and puts it back in front of people. `reopenFrom` limits which closed states reopen; by default all of them do. ## Data and rendering `data` is a schema for what your app sends. It's validated in your app before anything leaves it, and its type flows into `render`, `title` and everything else that reads `item.data`. `render` turns an item into [blocks](/build/blocks). It runs in your app when the item is sent, so it can read from your database. `title` builds a title from the data when your app doesn't pass one. The item passed to `render` also has `occurrences`, `users`, `firstSeen`, `lastSeen` and `due`. These are live values: put them in a block and they stay current as the item changes. ## Linking to a resource `resource` names the [resource](/build/resources) an item is about. When your app sends `record: user.id`, the item links to that user, shows that user's actions and counts how many different users hit it. Records mentioned in blocks are linked too, so one item can offer actions on several records. ## Layout and columns `layout` is `"list"` (the default), `"table"` or `"board"`. Boards group items by state and let people drag them between columns. `columns` picks what tables show: `title`, `state`, `occurrences`, `users`, `lastSeen`, `firstSeen`, `due`, `assignee`, `tags`, or any field of your data as `data.`. In the list layout, `data.*` columns also appear under each title. ```ts theme={null} columns: ["title", "occurrences", "users", "data.version", "lastSeen"], ``` ## Response times `sla` sets how long an item may sit in a state: ```ts theme={null} sla: { open: "4h", waiting: "2d" }, ``` Durations are minutes, hours or days (`"30m"`, `"4h"`, `"2d"`). Items show when they're due and turn red once overdue, lists can be sorted by due date and filtered to only overdue items, and the sidebar counts them. The `"overdue"` [notification](/build/notifications) trigger tells people when it happens. ## Public forms `intake` gives the inbox a public form anyone can submit, built from its data schema: ```ts theme={null} intake: { title: "Contact support", description: "We usually reply within a day.", }, ``` The form lives at `app.pult.sh/i//` and is rate limited. Submissions are rendered by your app like anything else. ## Icons `icon` is one of `archive`, `bot`, `box`, `bug`, `building`, `code`, `flag`, `git-branch`, `hash`, `house`, `inbox`, `life-buoy`, `mail`, `message`, `rocket`, `settings`, `shield`, `user` or `users`. Resources take the same names. # Notifications Source: https://docs.pult.sh/build/notifications Tell the right people when something arrives, comes back, spikes or sits too long. ```ts theme={null} import { notify, slack } from "pult" export const moderation = notify("moderation", { inboxes: [reports, abuse], on: ["created", "reopened", "overdue"], to: [mod, slack("moderation")], }) export const crashSpikes = notify("crash-spikes", { inboxes: [crashes], on: [{ spike: 20 }, "reopened"], to: [slack("eng")], }) ``` Notification rules are separate from inboxes and roles, so they can point at both without import cycles. ## Triggers * `"created"`: a new item arrives. * `"reopened"`: a closed item comes back. * `"overdue"`: an item passes its [response time](/build/inboxes#response-times). * `{ spike: n }`: an item happens `n` times within an hour. A new item notifies once, a reopened one at most hourly, a spike at most every six hours, and an overdue one each time it passes its due time. ## Recipients Roles are emailed: everyone with the role gets the message. `slack("channel")` posts to a Slack channel through an incoming webhook that an admin adds in the project's Slack settings, so the webhook URL never lives in your code. Notices wait about twenty seconds before they go out, so a burst arrives as one message listing the items, each with a link. ## Personal notices Some notices go to one person without any rule: when someone else assigns them an item, mentions them in a note, or a customer writes back on an item assigned to them. # Pages Source: https://docs.pult.sh/build/pages Dashboards, tools and the home page, made of blocks and placed in the sidebar. A page is a list of [blocks](/build/blocks). It can show your app's numbers next to Pult's live counts, put buttons and forms for any action in one place, and carry the flags your team flips most. A growth dashboard, an operations panel with a "broadcast message" form, a page for the person on call: what a page is for is up to you. Pages show up in the sidebar and in ⌘K. ```ts theme={null} import { b, page } from "pult" import { z } from "zod" export const growth = page("growth", { icon: "chart", params: z.object({ days: z.enum(["7", "30", "90"]).default("30") }), render: async ({ params }) => { const span = Number(params.days) return [ b.stats({ Signups: await stats.signups(span), MRR: { value: await stats.mrr(), note: "before refunds" }, }), b.chart(await stats.signupsByDay(span), { title: "Signups" }), b.columns([b.flag(signupLimit)], [b.actions(purgeCache)]), ] }, }) ``` ## Fixed or rendered `render` is either a list of blocks or a function that returns one. A list is fixed in code. It ships with your definitions, opens instantly and works while your app is offline, which suits pages made of Pult's own blocks: counts, item lists, inbox charts, flags and buttons. A function runs in your app each time someone opens the page, so it can show anything your app knows. It receives `params` and the `actor` looking at it. Like every request from Pult, it's signed and goes over your app's connection. ## Parameters `params` turns the top of the page into a row of filters: a choice for each enum or boolean, a text box for strings and numbers. The values live in the address, so a filtered page can be shared as a link, and arrive in `render` validated and typed. Fields with a default start on it. ## Home `home` is the page everyone lands on: ```ts theme={null} import { b, home } from "pult" export default home([ b.columns( [b.count(reports, { filter: { state: "open" }, label: "Open" })], [b.count(regressions, { label: "Regressions" })], ), b.chart(crashes, { range: "24h", title: "Crashes, last 24h" }), b.items(regressions, { limit: 5 }), ]) ``` It takes the same blocks or function as any page. There's one per project. Without one, home shows how many items are open in each inbox. ## Who sees a page Everyone sees home. Other pages are granted in [roles](/build/roles) with `pages`, and admins see all of them. Blocks on a page still follow the viewer's role: counts and lists for inboxes they can't see, and buttons for actions they can't run, are left out. ## Staying current Pages fixed in code update as items change. Rendered pages render again after an action runs, so a button that changes your numbers shows its effect. # Resources Source: https://docs.pult.sh/build/resources Teach Pult how to fetch, find, list and show the records in your app. A resource is a kind of record in your app: users, organizations, orders, repositories. You give Pult a few functions you mostly already have, and every record gets a page in the console with its details, its history, the items about it and the actions your team can run on it. ```ts theme={null} import { b, resource } from "pult" import { z } from "zod" export const user = resource("user", { icon: "user", get: (id) => db.users.find(id), find: (query) => db.users.search(query), list: { filters: z.object({ plan: z.enum(["free", "pro", "team"]).optional() }), run: ({ query, filters, cursor, limit }) => db.users.page({ query, ...filters }, cursor, limit), }, title: (u) => u.username, subtitle: (u) => u.email, fields: (u) => ({ plan: u.plan, joined: u.createdAt }), url: (u) => `https://acme.com/admin/users/${u.id}`, render: (u) => [ b.fields({ email: b.sensitive(u.email), plan: u.plan, joined: u.createdAt, }), ], related: { orders: { resource: "order", list: (u) => db.orders.forUser(u.id) }, }, actions: (action) => ({ // see Actions }), }) ``` Put `get` and `find` before `title` and `render`, so TypeScript knows the record type by the time it reaches them. ## Fetching `get(id)` returns the record or nothing. It runs in your app whenever Pult needs the record: rendering its page, checking which actions apply, running one. Records are identified by `record.id`; pass `id: (record) => ...` if yours are keyed differently. `title` names a record everywhere it appears. `subtitle` adds a line in search results and pickers. ## Finding and browsing `find(query, limit)` powers search: the resource's page in the console, ⌘K and record pickers in action forms. Without it, a resource can't be searched. `list` lets people browse records instead of searching for one. `run` receives the search text, the filters people picked and a cursor, and returns `{ records, cursor }`; pass back a cursor to offer another page, or `null` when there's no more. `filters` is a schema whose enum and boolean fields become filter buttons and whose text and number fields become inputs. `fields(record)` picks what the browse table shows and what related lists show. Selecting rows in the table runs any of the resource's actions on all of them at once. ## Showing a record `render(record)` returns [blocks](/build/blocks) for the record's page. It runs in your app each time the page opens, so it's always current. `url(record)` adds an "Open in your app" link to the page, for when your own admin has more. ## Related records `related` lists records connected to this one. Each relation names a resource and lists its records: ```ts theme={null} related: { repos: { resource: "repo", label: "Repositories", list: (u) => db.repos.byOwner(u.id), }, messages: { resource: "message", list: (u, limit) => db.messages.byAuthor(u.id, limit), }, }, ``` They show as tables on the record's page, linked to their own pages. Resources refer to each other by name, so two resources can point at each other without an import cycle; `pult check` catches a name that doesn't exist. ## History A record's page shows everything done to it in Pult: actions, approvals and reveals. Your app can add its own events with `pult.log`; see [Hooks and history](/build/hooks). # Roles Source: https://docs.pult.sh/build/roles Who sees which inboxes and records, who runs which actions, and who can reveal sensitive values. ```ts theme={null} import { role } from "pult" export const mod = role("mod", { label: "Moderator", inboxes: [reports, abuse], resources: [user, message, repo], actions: [message.actions.hide, user.actions.restrict, user.actions.ban], }) export const support = role("support", { inboxes: [support, crashes], resources: [user, org], actions: [org.actions["*"], user.actions.resetTwoFactor, purgeCache], pages: [growth], flags: [newEditor], reveal: true, }) ``` A role grants: * `inboxes`: the inboxes its people see, with everything in them. * `resources`: the resources its people can search, browse and open. * `actions`: the actions they can run, on records or on their own. `resource.actions["*"]` grants every action on a resource, including ones added later. * `pages`: the [pages](/build/pages) in their sidebar. Everyone sees home. * `flags`: the [flags](/build/flags) they can change. Everyone can see flags. * `reveal`: whether they can reveal [sensitive values](/build/blocks#sensitive-values). * `status`: whether they can change components and post incidents on the [status page](/build/status). Roles add up: someone with two roles has everything both grant. Admins of the organization see and can do everything in its projects, and are the only ones who can delete items, manage keys and read the audit log. ## Assigning roles Invite people from the organization's members settings, then give them roles in the project's settings. Roles themselves only change in code, so who can do what is always in your repository and reviewed like anything else. `pult diff` shows who gains or loses which permission before a deploy, and a granted action that no longer exists in code simply stops working. ## Agents An agent is an API token with a role, created in the project's settings. It works through the same permissions and leaves the same history as a person. See [Console API](/reference/api). # Sending items Source: https://docs.pult.sh/build/sending Fingerprints, occurrences, regressions, tags, suggested actions and batching. ```ts theme={null} const result = await pult.send(crashes, { fingerprint: topFrame, title: `panic in ${topFrame}`, record: user.id, data: { stack, version, os }, tags: ["desktop"], }) ``` `send` validates `data` against the inbox's schema, renders the item in your app and sends it. It returns the item's `id`, whether it was `created` or `reopened`, and its `occurrences` count. ## Fingerprints The fingerprint decides what counts as the same problem. Items with the same fingerprint in the same inbox are one item: the second send adds an occurrence instead of a new item, updates the title and content, and bumps it to the top of the list. Pick something stable that describes the problem rather than the instance: the top stack frame of a crash, the id of a reported message, the email address of a support request. Without a fingerprint, every send is a new item. Pult keeps a sample of occurrences (the first fifty, then every fiftieth) with their data and blocks, so people can see how a problem varies. ## Regressions If the inbox reopens on new occurrences and the item is closed, sending its fingerprint again reopens it, tags it `regressed` and runs [triage](/build/triage) again if the inbox has it. ## Records `record` is the id of a record of the inbox's [resource](/build/resources). The item links to it, offers its actions, and counts how many different records have hit it ("users affected"). ## Suggesting an action Your app can suggest what to do: ```ts theme={null} await pult.send(abuse, { fingerprint: repo.id, record: repo.id, data: { signal: "crypto-miner", score: 0.97 }, suggest: [{ action: repo.actions.hide, reason: "Runs a crypto miner" }], }) ``` The suggested action comes first on the item and is highlighted. Its reason shows when someone hovers it or opens its form, and the form is prefilled if you pass `input`. ## Batching Calls to `send` made within a few milliseconds of each other go out as one request, up to 200 items. Fire as many as you like from a loop or a busy handler; you don't need to batch yourself. Each call still gets its own result. ## Merging People can merge items from the same inbox in the console. The kept item takes the others' occurrences, affected records, tags and history, and anything your app sends with the merged fingerprints lands on the kept item from then on. # Status pages Source: https://docs.pult.sh/build/status A public page with your components, their uptime and your incidents, kept up to date by Pult watching your app. Pult already talks to your app all day, so it knows when your app stops answering. A status page turns that into something your customers can see. It also tells your team the moment something goes down. ```ts theme={null} import { slack, status } from "pult" import { oncall } from "./roles" export const statusPage = status({ title: "Acme", components: { api: { label: "API", monitored: true }, web: { label: "Website", check: "https://acme.com/health" }, git: "Git hosting", }, alert: [oncall, slack("incidents")], }) ``` A project has one status page. It runs in your last environment, the one your config treats as production, and its address is `status.pult.sh//`. ## Components Each component is something your customers recognise. It can be kept up to date in three ways: * **`monitored: true`** follows Pult's connection to your app. If your app uses `listen()`, Pult notices when the connection drops. With webhooks, Pult sends your app a signed ping every minute, which the `pult` package answers for you. The component goes down when your app has been unreachable for longer than `after` (two minutes by default, so a restart during a deploy doesn't count), and it comes back as soon as your app does. * **`check`** is an address Pult fetches on a schedule. Any response in the 200s or 300s counts as up. Use it for the parts your customers actually load, like your website or a health endpoint. * **Everything else** is set by your team in the console, or by your app. Your app can set any component's state when it knows better: ```ts theme={null} await pult.status.set("git", "degraded") ``` The states are `operational`, `degraded`, `partial`, `down` and `maintenance`. ## Alerts `alert` takes roles and Slack channels, like [notifications](/build/notifications). Everyone in them hears when a monitored component or a check goes down, and again when it's back, with how long it was out. ## Incidents When something's wrong, post an incident from Status in the console: a title, where it stands (investigating, identified, monitoring or resolved), what people should know, and how badly each component is affected, from degraded performance to a major outage. That impact is what the top of your page shows. Each update you post appears on the public page right away. Resolving an incident puts its components back to operational, except ones Pult still sees failing, which stay as they are until your app or their check recovers. Pult never posts incidents on its own, so the words customers read are always yours. Roles need `status: true` to change components and post incidents. Admins always can. Every change is in the audit log. ## The public page The page shows each component's state, 90 days of history with its uptime, the incidents happening now and the ones from the last two weeks. Older incidents are on its history page, kept for as long as your plan keeps history. It's plain HTML that loads instantly and works in light and dark. ## Making it yours ```ts theme={null} export const statusPage = status({ title: "Acme", logo: { light: "https://acme.com/logo.svg", dark: "https://acme.com/logo-dark.svg" }, website: "https://acme.com", css: readFileSync(new URL("./status.css", import.meta.url), "utf8"), components: { ... }, }) ``` * `logo` replaces the title at the top. Pass one address, or a light and a dark one. * `website` is where the logo or title links to. * `css` is added after the page's own styles, so anything you write wins. Any way of getting a string works, whether it's a file you read or a template literal. The colors are variables on `:root`, set separately for light and dark: `--bg`, `--panel`, `--raised`, `--line`, `--text`, `--muted`, `--faint`, and the state colors `--signal` (operational), `--warn` (degraded and partial outages), `--danger` (major outages) and `--info` (maintenance). The parts of the page have plain class names you can target: `.overall`, `.component`, `.label`, `.state`, `.bars`, `.bar`, `.legend`, `.incident` (with `.active` while it's ongoing), `.update`, `.when` and `.meta`. A component or bar in a given state also has `.state-operational`, `.state-degraded` and so on. ```css theme={null} @import url("https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&display=swap"); :root { --signal: #6d5bd0; } @media (prefers-color-scheme: dark) { :root { --bg: #0b0b12; } } body { font-family: "Instrument Sans", sans-serif; } ``` Keep it under 50 KB. `pult check` tells you if a logo or website isn't a web address. ## Your own domain On the Team plan and up, you can serve it from your own domain. Add the domain in your project's settings under Status page, then point a CNAME record at the address shown there. It goes live with its own certificate a few minutes later. ## What each plan includes Every plan gets a status page, app monitoring and alerts. Plans differ in how many addresses Pult checks and how often: | | Free | Team | Business | Enterprise | | - | - | - | - | - | | Checks | 3 | 10 | 50 | No limit | | How often | Every 5 minutes | Every minute | Every 30 seconds | Every 30 seconds | | Custom domain | No | Yes | Yes | Yes | Checks beyond your plan's number stay on the page as you set them, and the console tells you they aren't being checked. # Triage Source: https://docs.pult.sh/build/triage Typed questions about each new item, and rules in your code that act on the answers once they've earned it. Triage reads each new item and answers questions you ask about it. The answers are typed: a probability, one of your options, or a position on your scale. Rules in your code decide what to do with them, and new rules only watch until the numbers show they agree with your team. ```ts theme={null} import { choice, inbox, noul, run, setState, tag, triage } from "pult" export const reports = inbox("reports", { states: ["open", "actioned", "dismissed"], // ... triage: triage({ instructions: "Users report comments on our code hosting platform. " + "Judge the reported comment itself.", questions: { valid: noul( "Does `body` break the rules: spam, scams, harassment or porn?", ), category: choice( { spam: "Advertising, scams or links that try to sell something", harassment: "Insults or attacks aimed at people", fine: "Nothing wrong with it", }, "What is wrong with `body`?", ), }, rules: [ { name: "Dismiss reports on harmless comments", when: (a) => a.valid < 0.1, then: setState("dismissed"), }, { name: "Hide obvious spam", live: true, when: (a) => a.category === "spam" && a.valid > 0.95 && a.confidence > 0.9, then: [run(message.actions.hide), tag("spam")], }, ], }), }) ``` ## Questions Triage sees the item's title, its data and the text of its blocks, with [sensitive values](/build/blocks#sensitive-values) left out. `instructions` gives it context. Refer to fields of your data by name in the questions. * `noul(prompt, criteria?)` answers yes or no as a probability between 0 and 1. * `choice(options, prompt)` picks one option. Options are a list, or an object of option to description; describing options helps a lot. * `score(levels, prompt)` rates the item against your levels, lowest first. The answer is a number from 0 (the first level) to the last level's position, weighted by how sure it is: with `["low", "medium", "high"]`, 1.7 leans high. The answers' types follow your questions, so `a.category` above is `"spam" | "harassment" | "fine"`. `a.confidence` is the lowest confidence across the `choice` and `score` answers. ## Rules Rules run in your app, top to bottom. The first rule whose `when` matches decides, and `then` is one decision or a list: * `setState(state)` moves the item. Moving it to a closed state counts as handled by triage. * `tag(name)` tags it. * `run(action, input?)` runs an action. Only actions that are harmless or marked `automatable: true` can be run, never ones that need an approval; `pult check` catches the rest. ## Watching, then acting A rule without `live: true` only watches. Pult records what it would have done and compares that with what your team actually does with the item: an agreement when they end up in the same place, a disagreement when they don't. Each rule's agreement rate shows under Rules in the inbox. Turn a rule live once it has earned it. When a live rule closes an item and someone moves it back, that's recorded as an override and counts against the rule. ## Replay You can try rules out before shipping them. Change a rule, reload your app, and press Replay under Rules: your app runs its current rules against the stored answers for the last 500 items, and Pult compares the outcome with what people actually did. Nothing is asked again and nothing changes. ## In the console Items show triage's read at the top: the answers, which rule matched, and whether it acted or only watched. Items triage closed on its own are under Handled by triage in the sidebar, so someone can glance over them. # Views Source: https://docs.pult.sh/build/views Saved slices of an inbox for the whole team. A view is a filtered, sorted slice of one inbox that everyone sees in the sidebar: ```ts theme={null} import { view } from "pult" export const unassigned = view("unassigned-reports", { label: "Unassigned reports", inbox: reports, filter: { state: "open", assignee: "none" }, sort: "-lastSeen", }) export const regressions = view("regressions", { inbox: crashes, filter: { tags: ["regressed"], state: "open" }, layout: "table", columns: ["title", "occurrences", "users", "lastSeen"], }) ``` `filter` takes: * `state`: one state or a list. * `tags`: items must have all of them. * `assignee`: `"me"` for whoever's looking, or `"none"`. * `triaged`: only items triage closed on its own. * `search`: words in the title. `sort` is `lastSeen`, `firstSeen`, `occurrences`, `users`, `title` or `due`, with a `-` in front for descending. `layout` and `columns` work like an inbox's and default to it. People can also save their own filters as personal views from any list. Those stay theirs and don't need a deploy. To count or list a view's items on a page, use `b.count(view)` and `b.items(view)`. See [Pages](/build/pages). # Console API Source: https://docs.pult.sh/reference/api Work Pult's inboxes from your own tools and agents, with the same permissions as the console. Everything the console does goes through an HTTP API, and agents can use it too. An agent is a token with a role: it sees what the role sees, runs what the role allows, and everything it does is in the history and audit log under its name. ## Getting a token An admin creates an agent in the project's settings, under Agents, picks its role and copies the token, which is shown once. The same page shows the address of the current environment's API: ``` https://app.pult.sh/api/env/ ``` Send the token as a bearer token: ```bash theme={null} curl https://app.pult.sh/api/env/$ENVIRONMENT/items?inbox=reports \ -H "authorization: Bearer $PULT_AGENT_TOKEN" ``` Bodies are JSON. Errors come back with a status code and `{ "error": "..." }`, and some with a `code`. ## Items `GET /items` lists items, newest activity first, 50 at a time: | Query | Meaning | | - | - | | `inbox` | One inbox. Without it, every inbox the role sees. | | `view` | A view defined in code. | | `state` | Comma-separated states. | | `tags` | Comma-separated tags, all required. | | `assignee` | `none` or a person's id. | | `search` | Words in the title. | | `overdue=1` | Only items past their response time. | | `triaged=1` | Only items triage closed. | | `snoozed=1` | Only snoozed items. | | `sort` | `lastSeen`, `firstSeen`, `occurrences`, `users`, `title` or `due`, `-` in front for descending. | | `cursor`, `limit` | Paging. Pass back the `cursor` from the last page; `limit` goes up to 200. | Each item has `id`, `inbox`, `title`, `state`, `record`, `tags`, `assignee`, `occurrences`, `users`, `firstSeen`, `lastSeen`, `dueAt` and `fields`. `GET /items/:id` returns one item with its blocks, linked records, suggested actions, triage's answers and timeline. Changing an item: ```http theme={null} POST /items/:id/state { "state": "resolved" } POST /items/:id/assign { "assignee": "user-id" } null unassigns POST /items/:id/tags { "add": ["spam"], "remove": [] } POST /items/:id/snooze { "until": 1767225600000 } null wakes it POST /items/:id/notes { "text": "Checked the logs, it's the CDN." } POST /items/:id/reply { "subject": "Re: ...", "text": "..." } POST /items { "inbox": "support", "title": "...", "data": {} } ``` `POST /bulk` changes many at once: `{ "ids": [...], "operation": { "type": "state", "state": "resolved" } }`. Operations are `state`, `assign`, `tags`, `snooze` and `action`. ## Records ```http theme={null} GET /resources/:resource?q=lena search GET /resources/:resource/list?q=&filters={} browse, with a cursor GET /resources/:resource/:id page, items and history GET /resources/:resource/:id/related/:relation related records ``` ## Actions ```http theme={null} POST /actions { "action": "user.restrict", "record": "u_42", "input": { "features": ["comments"] }, "item": "the-item-id", "confirmed": true } ``` Leave out `record` for actions that don't take one. `item` is optional and records the action on that item's timeline. Actions with a danger level need `"confirmed": true`. Actions that need a fresh sign-in can't be run by agents; actions that need approval return `{ "pending": true }` and wait under Approvals for a person. The response is the action's result: `{ "message": "...", "blocks": [...] }`, `{ "queued": true }` if your app is offline, or `{ "running": true }` for background actions. `POST /available` with `{ "refs": [{ "resource": "user", "id": "u_42" }] }` says which actions apply to those records right now, and their defaults. ## Pages ```http theme={null} GET /pages/:page?params={"days":"7"} its blocks, with live data ``` ## Flags ```http theme={null} GET /flags every flag's value, rollout and overrides POST /flags/:flag { "change": { "value": true }, "confirmed": true } ``` A change can set `value`, `rollout` (a percentage, for on and off flags, or `null`), `targets` (values by record id), `rules`, and `ramp`: `{ "to": 100, "over": 86400000, "every": 3600000, "at": null }`, with durations and times in milliseconds, or `null` to stop one. Flags that need approval return `{ "pending": true }`. ## Approvals ```http theme={null} GET /approvals pending requests POST /approvals/:id/approve POST /approvals/:id/reject ``` ## Live updates `GET /live` upgrades to a WebSocket that streams item changes, timeline events, flag changes and counts for everything the role can see, as JSON messages with a `type`. # CLI Source: https://docs.pult.sh/reference/cli Build, check, diff and deploy your definitions with the pult command. The `pult` command comes with the `pult` package. Run it with `bunx pult`, `npx pult` or from a script. ```bash theme={null} bunx pult [--cwd path] ``` `--cwd` points it at another project folder. ## pult build Finds every file `include` matches in `pult.config.ts`, loads your definitions and writes: * `.pult/manifest.json`: everything Pult needs to know, with where each definition lives in your code. * `.pult/index.ts`: what `createPult` imports. Commit both. The manifest's version is a hash of its contents, so it only changes when something changes, and manifest changes show up in code review. If your definitions have any of the problems `pult check` looks for, `build` lists them and writes nothing. Definition files must not do anything when imported: no database calls, no network, no timers at the top level. Work belongs inside functions like `get` and `run`, which Pult calls later. ## pult check Builds without writing, then fails if anything's wrong: * A role grants an action that doesn't exist. * A page uses an action, inbox or flag that doesn't exist. * A scheduled action has an invalid cron expression, needs input without a default, or needs an approval. * A flag's default doesn't fit its value, or a guard has a limit below one an hour. * A view or triage rule uses a state the inbox doesn't have. * Triage runs an action that needs a human or an approval. * An action picks from a resource that doesn't exist or can't be searched, or names an input field it doesn't have. * A relation points at a resource that doesn't exist. * A column isn't part of the inbox's data. * A definition file does something when imported. * The committed `.pult/` is older than your code. Run it in CI. ## pult diff Shows what deploying would change in the environment behind `PULT_KEY`: inboxes, resources, actions and views added, removed or changed, danger levels and approvals that changed, and who gains or loses which permission. ## pult deploy Builds, prints the diff and deploys to the environment behind `PULT_KEY`. ```bash theme={null} PULT_KEY=pult_... bunx pult deploy ``` Your `sync` environment receives your definitions automatically, so `deploy` is for the others. Bun loads `.env` on its own; with Node, set `PULT_KEY` in your shell or CI. ## pult dev Rebuilds `.pult/` on every change to your code while it runs. Your app picks up the new definitions and syncs them to the `sync` environment the next time it connects or sends. # Using coding agents Source: https://docs.pult.sh/reference/coding-agents Hand the setup to a coding agent. Everything in Pult is code, so there's nothing it can't do. Everything about Pult lives in your repository: inboxes, resources, actions, roles, views, triage and notifications. That makes it a good job for a coding agent. It can read your app, write the definitions, check them and open a pull request, and your team reviews the result like any other change. ## Docs for agents These docs come in plain Markdown for agents to read: * [llms.txt](https://docs.pult.sh/llms.txt) lists every page with a one-line summary. * [llms-full.txt](https://docs.pult.sh/llms-full.txt) is all of the docs in one file. * Every page is also available as Markdown at its address plus `.md`, like [/build/actions.md](/build/actions.md). The menu at the top of each page also copies it or opens it in Claude or ChatGPT. Point your agent at `https://docs.pult.sh/llms-full.txt` before it starts. ## A starting prompt ``` Set up Pult in this app. Read https://docs.pult.sh/llms-full.txt first. 1. Install the pult package and add pult.config.ts. 2. Add a resource for each kind of record our support and moderation work touches, next to the code that loads it, with get, find, title and render. Add the actions our team does by hand today, with when conditions, and use danger, approval or reauth for anything destructive. 3. Add inboxes for the things we currently get alerted about, send items from where they happen, and use fingerprints so repeats land on one item. 4. Add a home page with the counts we watch, flags for the features we roll out gradually, and roles that match how our team is split up. 5. Run `bunx pult build` and `bunx pult check`, fix every problem, and commit .pult/ with the code. ``` Adjust the middle to what your team does. Agents do best with concrete examples: "we ban users from the admin panel at /admin/users", "crashes come from Sentry webhooks in src/webhooks/sentry.ts". ## Checking an agent's work `pult check` catches what an agent is most likely to get wrong: actions that don't exist, states that don't exist, triage running something dangerous, broken relations and pickers, and definitions that do work when imported. Run it in CI so nothing broken can merge. `pult diff` shows what a deploy would change, including who gains or loses which permission. Read it before deploying anything an agent wrote. ## Agents working in Pult Coding agents set Pult up; agents can also work in it. An agent token with a role can triage, assign, comment and run actions through the [Console API](/reference/api), with every step recorded under its name. # Connecting your app Source: https://docs.pult.sh/start/connecting Keys, environments, the WebSocket and webhook modes, and how definitions reach each environment. ## The client ```ts theme={"theme":{"light":"github-light","dark":"vitesse-dark"}} import { createPult } from "pult" import project from "../.pult" export const pult = createPult({ project }) ``` `createPult` reads `PULT_KEY` from the environment. You can pass `key` directly instead, `url` if you run Pult somewhere other than `https://app.pult.sh` (`PULT_URL` works too), and `fetch` to send Pult's requests some other way, like through a proxy. The client gives you: * `pult.send(inbox, item)` to send an item. See [Sending items](/build/sending). * `pult.listen()` to receive requests over a WebSocket. * `pult.handler()` to receive requests over HTTP instead. * `pult.log(resource, id, text)` to add to a record's history. See [Hooks and history](/build/hooks). * `pult.receive(message)` to thread an email reply onto its item. See [Email](/build/email). * `pult.flag(flag, key)` to read a flag. See [Flags](/build/flags). * `pult.deploy()` to push your definitions to the environment behind the key. ## Keys Each environment has its own key, created and rotated in the project's Environments settings. A key looks like `pult__`; the first part says which environment it belongs to, so one variable is all your app needs. Every request in both directions is signed with the key and carries a timestamp and a one-time nonce. Pult refuses anything unsigned, altered, older than five minutes or seen before, and the SDK does the same for requests from Pult. ## WebSocket or webhook By default your app opens a WebSocket to Pult with `pult.listen()` and keeps it open. Pult sends action requests, lookups and renders down it, and the SDK answers. This works anywhere a process stays up and needs nothing public. On serverless platforms, switch to webhooks: ```ts theme={"theme":{"light":"github-light","dark":"vitesse-dark"}} // pult.config.ts export default defineConfig({ project: "acme", include: ["src/**/*.pult.ts"], delivery: { mode: "webhook", path: "/api/pult" }, }) ``` Mount the handler on that route and enter your app's origin for each environment in the project's Environments settings: ```ts theme={"theme":{"light":"github-light","dark":"vitesse-dark"}} // Cloudflare Workers import { env } from "cloudflare:workers" const pult = createPult({ key: env.PULT_KEY, project }) const handle = pult.handler() export default { fetch(request: Request, _: Env, ctx: ExecutionContext) { const url = new URL(request.url) if (url.pathname === "/api/pult") return handle(request, ctx) return app.fetch(request, env, ctx) }, } ``` With the `nodejs_compat` flag on, Workers expose your secrets as `process.env` and `createPult({ project })` finds `PULT_KEY` by itself. Definition files can import `cloudflare:workers`, `cloudflare:email` and `cloudflare:sockets` at the top. `pult build` runs outside Workers, so it swaps them for stand-ins while it reads your definitions. A file can still set up a client with `env.DB` at the top, as long as nothing calls it while the file loads: do the actual work inside your functions. Pass your platform's context as the second argument so [background actions](/build/actions#background-actions) can keep running after the response. On serverless platforms, `await` every `pult.send` or hand it to `ctx.waitUntil`, so the platform doesn't stop your code before the item is sent. If your app can't be reached, Pult queues what it needed to deliver and retries with backoff. Actions and replies queued this way show as waiting in the timeline until your app answers. ## Environments Environments are listed in your config from development to production: ```ts theme={"theme":{"light":"github-light","dark":"vitesse-dark"}} export default defineConfig({ project: "acme", include: ["src/**/*.pult.ts"], environments: ["dev", "staging", "prod"], sync: "dev", }) ``` The `sync` environment (the first one unless you say otherwise) follows your code. Whenever your app connects, or sends an item built from definitions Pult hasn't seen, the SDK deploys your current definitions there. Pult refuses that automatic deploy for every other environment: those change only through `pult deploy`. The console marks the last environment as production. Data never crosses environments; each one has its own items, history and key. ## Deploying definitions ```bash theme={"theme":{"light":"github-light","dark":"vitesse-dark"}} PULT_KEY=pult_... bunx pult deploy ``` `pult deploy` builds, shows what changes (including who gains or loses which permission) and pushes the manifest. Removing an inbox from your code archives it: its items stay readable under "Removed from code" in the console. See [CLI](/reference/cli). # Introduction Source: https://docs.pult.sh/start/introduction What Pult is, and the handful of ideas everything else builds on. Pult is the other side of your app. Every product has work that needs a person: reports to review, crashes to chase, customers to answer, accounts to fix. Pult gives your team one fast place to do that work, and you describe all of it in code that lives next to your app. Nothing in Pult is configured by clicking. You define what exists in TypeScript, `pult build` turns it into a manifest, and the console follows. When someone in your team presses a button, Pult asks your app to do it, and your app decides. ## The pieces **Inboxes** are where things land. An inbox has a shape (the data your app sends), a look (how an item renders) and a set of states it moves through. Your app sends items in as things happen, and Pult keeps one item per problem however often it repeats. **Items** are what people work on. Each one has a title, a state, a timeline and whatever your app chose to show. Items can be assigned, tagged, snoozed, merged and closed, and they reopen on their own when the same problem comes back. **Resources** are the records in your app: users, organizations, orders, repositories. You tell Pult how to fetch, find and show them. Every resource gets its own pages in the console, and any item that mentions a record links to it. **Actions** are what your team may do. Ban a user, refund an order, purge a cache, send the weekly digest. Each action runs inside your app, with guardrails written next to it: when it applies, whether it needs confirmation, a fresh sign-in or a second person's approval. Some run on a schedule. **Pages** are dashboards and tools made of blocks: your app's numbers, Pult's live counts, buttons for actions and switches for flags, arranged however you like. Home is one of them. **Flags** are values your app reads and your team changes: features turned on for some users, kill switches, limits and settings. **Roles** decide who sees which inboxes, resources and pages, who may run which actions and who may change which flags. People and agents both work through roles. **Views, triage and notifications** shape the day to day: saved filters, rules that act on items automatically once they've proven themselves, and who gets told about what. Everything is made of the same few pieces, so they combine freely. A button for any action, a flag's switch or a live count can sit on a page, a record or an item. ## How it fits together ``` your app ──send──▶ Pult ──▶ console ──▶ your team your app ◀─action─ Pult ◀── console ◀── your team ``` Your app talks to Pult with the `pult` package. It sends items in over HTTPS and listens for requests from Pult over a WebSocket it opens itself, so your app needs no public endpoint. Everything in both directions is signed with your environment's key. Each project has environments, listed in your config from development to production. One of them follows your code as you work; the others change only when you deploy to them. ## Where to go next * [Quickstart](/start/quickstart) gets a first inbox working end to end. * [Resources](/build/resources) and [Actions](/build/actions) cover the part your team will use most. * [Using coding agents](/reference/coding-agents) explains how to hand the setup to an agent. # Quickstart Source: https://docs.pult.sh/start/quickstart From an empty project to a working inbox, with a record your team can act on. This takes about ten minutes. You'll end up with an inbox your app sends crashes into, linked to your users, with an action to reset a user's two-factor login. ## 1. Create a project Sign in at [app.pult.sh](https://app.pult.sh) and add a project. Pult creates its first environment and shows you its key once. Put it in your app's environment as `PULT_KEY`. ```bash theme={null} PULT_KEY=pult_... ``` ## 2. Install ```bash theme={null} bun add pult@npm:@lantharos/pult ``` npm, pnpm and yarn work the same way. The package is published as `@lantharos/pult` for now, and installing it under the name `pult` keeps your imports as `"pult"`. It's both the library your app imports and the `pult` command. ## 3. Add a config `pult.config.ts` sits at the root of your app. It names the project and tells Pult where your definitions live. ```ts theme={null} // pult.config.ts import { defineConfig } from "pult" export default defineConfig({ project: "acme", include: ["src/**/*.pult.ts", "pult/**/*.ts"], environments: ["dev", "prod"], }) ``` Definitions can live anywhere `include` matches. A common layout keeps resources next to the code they wrap (`src/users/user.pult.ts`) and inboxes, roles and views together in `pult/`. ## 4. Describe a resource ```ts theme={null} // src/users/user.pult.ts import { b, resource } from "pult" import { db } from "../db" export const user = resource("user", { get: (id) => db.users.find(id), find: (query) => db.users.search(query), title: (u) => u.username, render: (u) => [ b.fields({ email: u.email, plan: u.plan, joined: u.createdAt }), ], actions: (action) => ({ resetTwoFactor: action({ label: "Reset 2FA", danger: "confirm", when: (u) => u.twoFactor, run: ({ record }) => db.users.resetTwoFactor(record.id), }), }), }) ``` ## 5. Add an inbox ```ts theme={null} // pult/crashes.ts import { b, inbox } from "pult" import { z } from "zod" import { user } from "../src/users/user.pult" export const crashes = inbox("crashes", { icon: "bug", resource: user, states: ["open", "resolved"], reopenOn: "new-occurrence", data: z.object({ stack: z.string().max(20_000), version: z.string() }), render: (item) => [ b.chart("occurrences"), b.code(item.data.stack, { lang: "trace" }), ], }) ``` Schemas can come from zod, valibot, arktype or any library that implements Standard Schema. ## 6. Build ```bash theme={null} bunx pult build ``` This writes `.pult/manifest.json` and `.pult/index.ts`. Commit both: manifest changes then show up in code review like any other change. ## 7. Connect and send ```ts theme={null} // src/pult.ts import { createPult } from "pult" import project from "../.pult" export const pult = createPult({ project }) pult.listen() ``` `listen()` opens a WebSocket from your app to Pult. Pult sends action requests down it, so your app needs no public endpoint. Now send something: ```ts theme={null} import { crashes } from "../pult/crashes" import { pult } from "./pult" await pult.send(crashes, { fingerprint: topFrame, title: `panic in ${topFrame}`, record: currentUser.id, data: { stack, version: APP_VERSION }, }) ``` Open the console. The crash is in the Crashes inbox, linked to the user, with Reset 2FA on it for users who have two-factor turned on. Send the same fingerprint again and the count goes up instead of a new item appearing. The first environment in your config follows your code: whenever your app connects or sends something with definitions Pult hasn't seen, it deploys them there. See [Connecting your app](/start/connecting) for the rest. # Plans and billing Source: https://docs.pult.sh/use/billing What each plan includes, who counts as a seat, and what happens when you go over. Every plan has every piece of Pult: inboxes, pages, flags, triage, approvals and agents. Plans differ in how much you use, and in how many addresses your [status page](/build/status) checks and how often. Team and up can serve the status page from your own domain. Business adds audit log export, and Enterprise adds SSO and data residency. See [pricing](https://pult.sh/#pricing) for the numbers. ## Who you pay for On monthly plans you pay for the people who used Pult in the month: anyone who opened the console or used its API as themselves. Someone who's in the organization but didn't sign in that month costs nothing. Agents never count. Yearly plans are for a set number of seats, at a lower price. If more people start using Pult than you have seats for, add seats from the billing page. ## Usage Plan and billing, in the organization's settings, shows this month's active people, items and triage runs against what the plan includes. Usage starts again on the first of each month. * **Items**: going over never loses anything. Pult keeps taking every item your app sends. * **Triage**: on Free, triage stops for the rest of the month once the included runs are used up, and items wait for a person. On paid plans it keeps going, and runs beyond what's included are billed at the plan's rate. * **People, projects, environments and agents**: when a plan's limit is reached, adding another asks you to upgrade. Environments in your config beyond the plan's limit aren't created until you do. * **History**: timelines and the audit log keep events for as long as the plan says, and older ones are removed. ## Paying Payments go through Polar, which handles invoices and sales tax. Prices are in dollars in the US and the same numbers in euros everywhere else. Organization admins upgrade from Plan and billing and manage cards, invoices and cancellations from Manage billing. Canceling keeps your plan until the end of the period you paid for, then the organization moves to Free. Nothing is deleted, though history beyond Free's 30 days is removed over the following days. # Working in the console Source: https://docs.pult.sh/use/console The keyboard, queues, bulk changes, approvals and working alongside your team. The console is built to be fast with a keyboard and to stay out of the way. Press `?` anywhere for every shortcut. ## Moving around | Key | Does | | - | - | | `⌘K` / `Ctrl K` | Search items, records and pages, and run actions | | `j` / `k` | Next and previous item | | `/` | Search the current list | | `q` | Work through the list one item at a time | | `Esc` | Clear the selection, then close the item | ⌘K offers the open item's actions first, so typing "ban" while reading a report bans its author. Actions that don't need a record, like purging a cache, are there by name. ## On an item | Key | Does | | - | - | | `e` | Close it and open the next one | | `E` | Reopen it | | `m` | Move it to a state | | `a` | Assign it to yourself | | `s` | Snooze it | | `t` | Tag it | | `c` | Write a note | | `1` to `9` | Run the first action, the second, and so on | Closing with `e` shows a toast that lets you undo. ## Queues Press `q` in any list to take items one at a time, full width. Actions are numbered, `e` closes the item and the next one is already there, and `Esc` takes you back to the list. ## Many at once In any list, `x` selects the item you're on, shift-click selects a range and `⌘A` selects everything loaded. A bar appears for moving, assigning, tagging, snoozing, running an action on, merging or deleting them all. On a resource's page, selecting rows runs that resource's actions on every selected record. ## Lists Lists can be sorted, filtered to yours, unassigned or overdue items, searched, and narrowed to a tag by clicking it. Filters you set can be saved as a personal view. Each person can switch between comfortable and compact lists, and between light and dark, from the menu under their name. ## Working together You can see who else has an item open, and when they're writing a note. Type `@` in a note to mention someone; they get an email. Notes can be edited by whoever wrote them. Items that share a user, repository or any other record with the one you're reading are listed under it, so related problems are one click away. ## Pages and flags Pages your role can see are listed in the sidebar, and flags have a page of their own. Values with a pencil next to them can be changed right where they are. ## Approvals Actions and flag changes that need a second person wait under Approvals in the sidebar. Whoever asked can withdraw the request; anyone else allowed to run the action can approve or reject it. ## Audit log Admins can read the audit log: every action, approval, flag change, state change, assignment, note, reveal, deletion and deploy in the environment, newest first. ## Organizations and settings Projects belong to an organization, which holds the people, the plan and the billing. Links into the console start with the organization's address, then the project and the environment, like `app.pult.sh/acme/shop/prod`. Your name at the bottom of the sidebar opens Settings, which keeps everything in one place: * **Your account**: your name, theme and list density, passkeys and the devices you're signed in on. You can delete your account here too. * **Each organization**: its name and address, its members and invite links, and its plan and billing. Admins of the organization can change everything in it, including every project. * **Each project**: its name and address, environments and their keys, who holds each role, Slack channels, agents and deploys. ## On a phone On a narrow screen the sidebar becomes a menu and the list and the item take turns on screen, so you can work a queue from anywhere. # Security Source: https://docs.pult.sh/use/security What Pult can and can't do with your app, and the controls you have over it. ## Your app keeps control Pult never connects to your database or your infrastructure. Everything it knows about your records comes from functions in your app, and everything it does to them is an action your app runs and can refuse. If you remove an action from your code, nobody can run it, whatever their role. ## Signed both ways Every request between your app and Pult is signed with your environment's key, using HMAC-SHA256 over the method and path, a timestamp, a one-time nonce and the body. Pult refuses requests that are unsigned, altered, more than five minutes old or seen before, and the SDK applies the same checks to requests from Pult. Keys are stored encrypted, can be rotated at any time, and each environment has its own. ## Permissions live in code Roles, and which inboxes, resources, actions, pages and flags they grant, are defined in your repository and reviewed like any other change. `pult diff` shows who gains or loses which permission before you deploy. People are assigned roles in the console; agents get exactly one role each. ## Guardrails on actions Actions and flags can ask for confirmation, a fresh sign-in, or a second person's approval, and you choose which in code. Triage can only run actions you've marked safe for it, never ones that need an approval. See [Actions](/build/actions#guardrails). ## Sensitive values Values marked sensitive never reach the browser until someone allowed to reveals them, and every reveal is recorded. Triage never sees them. See [Blocks](/build/blocks#sensitive-values). ## Accounts There are no passwords to leak or reuse. People sign in with a passkey or a one-time code sent to their email, so every account belongs to someone who controls its address. Codes work for 10 minutes and a few tries, and repeated attempts from one address are slowed down. Dangerous actions ask for a fresh passkey or code first. ## A record of everything Every action, approval, flag change, state change, note, reveal, deletion and deploy is kept in the timeline of the item or record it touched and in the environment's audit log, with who did it. ## Email Pult never sends mail from your domain. Customer email goes through your own app and provider. See [Email](/build/email).