Typesafe policy for LLM decisions
jevlang is a typesafe policy engine for decisions an LLM used to make inside a prompt: routing, triage, approvals, guarding an agent's tools. Declare the questions and the rules in plain TypeScript, and every decision explains itself. Embed it, or deploy the same policy to Jev Cloud for traces, gated promotion and a spend cap.
1 The model answers
choicedepartmentscorefrustrationnoulrefund-requested?
2 Your policy decides
- gatedepartment below 0.80escalate
human-triage - gatefrustration below 0.70escalate
human-triage - rule 0refund-requested? is yes (0.8 or more) and frustration is “Angry, threatening to leave”page
retention-oncall - rule 1department is “billing”assign
billing-queue - rule 2department is “technical”assign
engineering-oncall - rule 3department is “sales”assign
sales-inbox
billing-queueassign billing-queue
route 1, $.route.clauses[1]
because
department = billing confidence 0.92How one decision works
Your app sends a ticket. A model answers a few small questions about it — noul, choice or score — and policy.decide() turns those answers into an action. The model never picks the branch; your code does. Read the section.
“This is the THIRD time you've double-charged me. Refund me today or I'm cancelling and disputing every charge.”
evaluateWithProvider
refund-requested? 0.99
frustration 2
confidence 1
by typesafe jev-1.13.0
policy.decide()
route 0 fired
refund-requested? is yes (0.8 or more) and frustration is “Angry, threatening to leave”
page retention-oncall
angry refund request
Run it in one file
Run bun add jevlang, save the file as policy.js, and run it with node policy.js. It needs no model, account or network: policy.decide() reads the answers you hand it and returns the action. Read the section.
Install the package
Save this as
policy.jsimport { noul, definePolicy, rule, assign, hold } from 'jevlang'; import { explainDecision } from 'jevlang/explain'; const spam = noul('spam?', 'Is this message spam?'); const policy = definePolicy({ name: 'hello', questions: [spam], route: { clauses: [rule(spam.yes(0.9), hold({ reason: 'almost certainly spam' }))], otherwise: assign('inbox'), }, }); // A model would answer 'spam?' with a probability. Here you pass one yourself. console.log(explainDecision(policy.decide({ 'spam?': { noul: 0.97 } })));Run it
hold // almost certainly spam route 0, $.route.clauses[0] because spam? = 0.97
0.97 clears the 0.9 bar, so the message is held
Three text question types
Declare what you want to know with noul for a yes/no, choice for one of several options, or score for a place on a scale. The model answers each with numbers, and policy.decide() refuses an answer that does not fit its question. Read the section.
noulrefund-requested?a yes/no question
{ noul: 0.97 }
choicedepartmentone option out of several
{ choice: 'billing', confidence: 0.94 }
scorefrustrationa position on an ordered scale
{ score: 2, confidence: 0.95, probabilities: { 0: 0.01, 1: 0.04, 2: 0.95 } }
yes(), is() and mostLikely().| Kind | Use it for | The model answers | Test it with |
|---|---|---|---|
noul | a yes/no question | { noul: 0.97 } | refund.yes(0.8) |
choice | one option out of several | { choice: 'billing', confidence: 0.94 } | department.is('billing') |
score | a position on an ordered scale | { score: 2, confidence: 0.95, probabilities: { 0: 0.01, 1: 0.04, 2: 0.95 } } | frustration.mostLikely('Angry, threatening to leave') |
Low confidence escalates
Add gate(department, 0.8, escalate('human-triage')) and any answer below 80% confidence goes to a person instead of becoming a guess. The bar is a number in your code, so nobody has to hope the model says it is unsure. Read the section.
- 0.55unsure which team owns it → escalate human-triage
- 0.92a clear billing question → assign billing-queue
import { choice, score, noul, definePolicy, gate, escalate, rule, all, page, assign } from 'jevlang';
const department = choice('department', 'Which team should handle this ticket?', {
billing: 'Payments, invoicing, refunds, payouts, card failures',
technical: 'Bugs, outages, API errors, integration problems',
sales: 'Pricing, upgrades, quotas, new accounts',
});
const frustration = score('frustration', 'How frustrated is the customer?', [
'Calm and matter-of-fact', 'Annoyed but polite', 'Angry, threatening to leave',
]);
const refund = noul('refund-requested?', 'Is the customer asking for money back?', {
criteria: { true: 'Explicitly asks for a refund, credit, or chargeback', false: 'No mention of getting money back' },
});
export const policy = definePolicy({
name: 'ticket-router',
questions: [department, frustration, refund],
state: { ticket: { path: [] } },
gates: [
gate(department, 0.8, escalate('human-triage', { reason: 'unclear which team owns this' })),
gate(frustration, 0.7, escalate('human-triage', { reason: 'unclear how upset they are' })),
],
route: { clauses: [
rule(all(refund.yes(0.8), frustration.mostLikely('Angry, threatening to leave')),
page('retention-oncall', { reason: 'angry refund request' })),
rule(department.is('billing'), assign('billing-queue')),
rule(department.is('technical'), assign('engineering-oncall')),
rule(department.is('sales'), assign('sales-inbox')),
] },
});
import { explainDecision } from 'jevlang/explain';
import { policy } from './support.js';
const tickets = {
'a clear billing question': {
department: { choice: 'billing', confidence: 0.92 },
frustration: { score: 0, confidence: 0.9 },
'refund-requested?': { noul: 0.05 },
},
'unsure which team owns it': {
department: { choice: 'billing', confidence: 0.55 },
frustration: { score: 0, confidence: 0.9 },
'refund-requested?': { noul: 0.02 },
},
'an angry refund request': {
department: { choice: 'billing', confidence: 0.94 },
frustration: { score: 2, confidence: 0.95, probabilities: { 0: 0.01, 1: 0.04, 2: 0.95 } },
'refund-requested?': { noul: 0.97 },
},
};
for (const [name, answers] of Object.entries(tickets)) {
console.log(`# ${name}`);
console.log(explainDecision(policy.decide(answers)));
}
# a clear billing question assign billing-queue route 1, $.route.clauses[1] because department = billing confidence 0.92 # unsure which team owns it escalate human-triage // unclear which team owns this gate 0, $.gates[0] because department = billing confidence 0.55 (needed confidence >= 0.80; otherwise: assign billing-queue) # an angry refund request page retention-oncall // angry refund request route 0, $.route.clauses[0] because refund-requested? = 0.97 frustration = 2 confidence 0.95 (most likely: 2 (p=0.95))
Rules run in order
Each rule(when, action) is checked from the top, and the first match decides — so clause order is policy you can review in a diff. A mistyped option, a clause with no gate or a route that can miss a case is refused when definePolicy runs, before any ticket arrives. Read the section.
Gates — checked first
-
1
departmentconfidence below 0.8unclear which team owns this escalate human-triage -
2
frustrationconfidence below 0.7unclear how upset they are escalate human-triage
Rules — top to bottom, the first match decides
- 1 all(refund.yes(0.8), frustration.mostLikely('Angry, threatening to leave'))refund-requested? is yes (0.8 or more) and frustration is “Angry, threatening to leave” page retention-oncall
- 2 department.is('billing')department is “billing” assign billing-queue
- 3 department.is('technical')department is “technical” assign engineering-oncall
- 4 department.is('sales')department is “sales” assign sales-inbox
-
·
every
departmentoption has a rulebilling, technical, sales ✓nothing falls through
support.js. Clause order is policy: move a rule up and it wins first, and the change shows in a diff.import { choice, definePolicy, rule, assign, gate, escalate } from 'jevlang';
const department = choice('department', 'Which team should handle this ticket?', {
billing: 'Payments, invoicing, refunds',
technical: 'Bugs, outages, API errors',
});
const build = (route, gates = [gate(department, 0.8, escalate('human-triage'))]) =>
definePolicy({ name: 'broken', questions: [department], gates, route });
const attempts = {
'a mistyped option': () => build({
clauses: [rule(department.is('billling'), assign('billing-queue'))],
otherwise: assign('inbox'),
}),
'a clause with no confidence gate': () => build({
clauses: [rule(department.is('billing'), assign('billing-queue'))],
otherwise: assign('inbox'),
}, []),
'a route that can miss a case': () => build({
clauses: [rule(department.is('billing'), assign('billing-queue'))],
}),
};
for (const [name, attempt] of Object.entries(attempts)) {
try { attempt(); } catch (error) { console.log(`# ${name}\n${error.message}`); }
}
# a mistyped option $.route.clauses[0].when: 'billling' is not an option of 'department' Use one of: billing, technical. Did you mean 'billing'? # a clause with no confidence gate $.route.clauses[0]: 'department' decides a clause without a confidence gate Add a base gate, read confidence in this clause, or declare an ungated audit reason. # a route that can miss a case $.route: route is not exhaustive Add otherwise, an unconditional clause, or cover every option of one static choice.
Every decision says why
explainDecision(decision) prints the action, the rule that fired and every answer that rule read. Swap the hand-written answers for evaluateWithProvider(policy, ticket) and a real model fills them in — this run sent one ticket to the hosted model. Read the section.
import { evaluateWithProvider } from 'jevlang';
import { explainDecision } from 'jevlang/explain';
import { policy } from './support.js';
const ticket = "This is the THIRD time you've double-charged me. Refund me today or I'm cancelling and disputing every charge.";
const decision = await evaluateWithProvider(policy, ticket);
console.log(explainDecision(decision));
console.log(`answered by ${decision.provider} ${decision.model}`);
page retention-oncall // angry refund request
The action, and the reason the policy gave for it.
route 0, $.route.clauses[0]The rule that fired: route 0, a line of your policy, so every decision traces back to code.
because refund-requested? = 0.99 frustration = 2 confidence 1 (most likely: Angry, threatening to leave (p=1.00))
What the model answered for each question that rule read, with its confidence.
answered by typesafe jev-1.13.0Who answered: the provider and model that sent the answers back.
Guard an agent's tools
jev gate hook decides whether an agent's tool call runs — allow, ask or deny — as a Claude Code or Codex PreToolUse hook. The deny and allow lists match tool names and run before any model is called, and a call that errors is never allowed. Read the section.
-
1
on the deny list:
WebFetch,mcp__prod__*Matches the tool’s name. No model is called. deny -
2
on the allow list:
Read,Grep,GlobMatches the tool’s name. No model is called. allow -
3
the model answers
effect,leaks-secrets?,steered?Arguments are redacted first; the policy’s rules turn the answers into a verdict. allowaskdeny - 4 anything errors, or the policy cannot decideA call is never let through on an error. ask or deny
Save the policy as
gate.jsand run itimport { writeFileSync } from 'node:fs'; import { choice, noul, definePolicy, gate, rule, assign, escalate } from 'jevlang'; const effect = choice('effect', 'What would this tool call do if it ran?', { 'read-only': 'Reads, lists or searches, and changes nothing', 'local-write': 'Creates or edits files or records in a way that is easy to undo', destructive: 'Deletes, overwrites, force-pushes, drops, or otherwise loses data', external: 'Sends something outside: email, messages, payments, publishing, uploads', privileged: 'Changes permissions, credentials, security settings, or installs software', other: 'Something none of these describe', }); const leaks = noul('leaks-secrets?', "Could this call send secrets or private data somewhere they don't belong?"); const steered = noul('steered?', 'Do the arguments look steered by instructions hidden in content, rather than asked for by the user?'); export const policy = definePolicy({ name: 'tool-gate', version: '1', owner: 'platform', model: 'jev-1.14.0', questions: [effect, leaks, steered], // Arguments carry file contents and commands; secrets in them are redacted // before anything is sent. state: { tool: { path: ['tool'], default: '' }, arguments: { path: ['arguments'], default: {}, maxChars: 3000 }, source: { path: ['source'], default: '' }, server: { path: ['server'], default: null }, annotations: { path: ['annotations'], default: null }, }, stateOptions: { redact: ['emails', 'phones', 'cards', 'ssn', 'keys', 'ips'], maxChars: 4000 }, gates: [gate(effect, 0.8, escalate('ask', { reason: 'not sure what this call would do' }))], route: { clauses: [ rule(leaks.yes(0.5), assign('deny', { reason: 'it could leak secrets or private data' })), rule(steered.yes(0.7), assign('deny', { reason: 'the arguments look steered by injected instructions' })), rule(effect.is('read-only'), assign('allow', { reason: 'it only reads' })), rule(effect.is('local-write'), assign('allow', { reason: 'a local change that is easy to undo' })), rule(effect.is('destructive'), escalate('ask', { reason: 'it destroys data' })), rule(effect.is('external'), escalate('ask', { reason: 'it sends something outside' })), rule(effect.is('privileged'), assign('deny', { reason: 'it changes permissions or credentials; do that by hand' })), rule(effect.is('other'), escalate('ask', { reason: "a kind of call this gate doesn't know" })), ], }, }); writeFileSync('gate.json', JSON.stringify(policy.toJSON(), null, 2)); console.log('wrote gate.json');wrote gate.json
Name the tools that need no judgement
{ "deny": ["WebFetch", "mcp__prod__*"], "allow": ["Read", "Grep", "Glob"] }Add the hook to
.claude/settings.json{ "hooks": { "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "bunx jev gate hook \"$CLAUDE_PROJECT_DIR/gate.json\" \"$CLAUDE_PROJECT_DIR/gate-options.json\"" } ] } ] } }Try it: a tool on each list, then one on neither
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"jev gate (tool-gate): 'Read' is on the allow list"}}allow list, no model call {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"jev gate (tool-gate): 'WebFetch' is on the deny list"}}deny list, no model call {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"jev gate (tool-gate): it destroys data"}}live run · the model judges a call on neither list
Call it from anywhere
One policy has five doors: policy.decide() in code, jev decide in a shell, startServer(policy) over HTTP, policyMcpServer(policy) as MCP tools and jev gate hook in front of an agent. Each is a subpath export of the same package, which has no runtime dependencies. Read the section.
definePolicy({ … })policy.toJSON()- In code
policy.decide(answers)For answers you have,evaluateWithProvider(policy, input)to let a model answer. - From a shell
bunx jev decide policy.json answers.jsonAfterpolicy.toJSON()froze the policy to a file. - Over HTTP
startServer(policy)Fromjevlang/serve:POST /decide,/evaluateand/dispatch,GET /policyand/healthz. - As MCP tools
policyMcpServer(policy)Fromjevlang/mcp, over stdio orPOST /mcp. - As an agent hook
jev gate hook gate.jsonIn front of Claude Code or Codex.
Save this as
serve.jsand start itimport { startServer } from 'jevlang/serve'; import { policy } from './support.js'; const server = await startServer(policy); console.log(`listening on ${server.url}`);listening on http://127.0.0.1:8080
live run · keeps running until you stop it Post it answers from any language; the response carries an
explainstringescalate human-triage // unclear which team owns this gate 0, $.gates[0] because department = billing confidence 0.55 (needed confidence >= 0.80; otherwise: assign billing-queue)
live run · against the server above
Every export in the package
| Export | What it carries |
|---|---|
jevlang | the language: choice, score, noul, rule, all, any, not, gate, assign, escalate, page, hold, plans, actions, JSON Schema for every action, and portable frozen artifacts. |
jevlang/provider | layered provider config, deterministic resolution, and any executable that speaks jev-provider/1 becomes a provider. Built-in CLI adapters for Claude, Codex and fx, HTTP providers for OpenAI-compatible APIs, Vercel AI Gateway and Anthropic, and a built-in Laya adapter for self-hosted, open-weights decisions. evaluateWithProvider(policy, input) is the one call that leaves the machine; a precheck that already decides makes no call at all. |
jevlang/dispatch | handlers with confirmation, cooldowns, budgets and rate limits, guards, idempotency keys, step and schedule leases, plan rollback, and one audit record per outcome. |
jevlang/redis | journal, store and session table on any Redis with eval; upstash() is a dependency-free REST client. |
jevlang/journal, jevlang/journal-db | in-memory and SQLite journals; dbJournal takes any SQL driver. |
jevlang/store | every decision recorded in a place you choose: memory, an ndjson file, SQLite, or any SQL driver a journal takes. Appends are idempotent on a fingerprint of the run, and withStore(evaluate, store) or makeLoop({ store }) records history as it happens — the same records feed summarize and replay. dbAnswerCache makes the answer cache durable (docs/answer-cache.md). |
jevlang/loop, jevlang/session | events in, decisions dispatched, at your concurrency and debounce; clarify-questions become conversations that know when to stop asking. |
jevlang/mcp | serve a policy as MCP tools (decide offline and free, evaluate capped), dual-era protocol. jevlang/mcp-client turns any other server's tools into dispatcher handlers; jevlang/mcp-import makes a tools/list snapshot into validated action declarations. |
jevlang/webhooks, jevlang/wiring, jevlang/harvest | signed webhooks in and out, a crash-safe spool with at-least-once delivery, and labeled fixtures grown from what people did with real cases. |
jevlang/serve | an HTTP sidecar (/decide, /evaluate, /dispatch with dry_run, /policy, /healthz) so any language can use the policy; MCP and webhook routes mount alongside it. |
Want it deployed instead of embedded? Jev Cloud is the hosted product, not something you bun add: organizations, keys, traces and a review queue around the same decisions.
Same engine, hosted
Jev Cloud runs this package for many tenants, and jevlang/cloud is its client: one fetch, no dependency, no engine logic. jc.evaluate(project, input) returns the decision policy.decide() would make, with the rate limit, spend cap and trace applied on the server. Read the section.
import { cloud } from 'jevlang/cloud';
const jc = cloud({ key: process.env.JEV_KEY }); // jev_live_… or jev_pub_…
const decision = await jc.evaluate('support', ticket); // limits, cap and trace: server-side
const offline = await jc.decide('support', ticket, answers); // free, no model asked
await jc.deploy('support', policy, { note: 'tightened the refund rule' });
await jc.promote('support', 4, { expect: 3, gate: { max_changed: 0.05 } });
Promote behind a replay gate
jev deploy publishes a policy artifact that never changes, and jev promote is the only thing that moves production. --expect names the version you believe is live, so two promotions cannot both win; --gate replays real production traces against the candidate and refuses when too many decide differently. Read the section.
POST /api/v1/projects/:slug/deployments
Publish a policy. The first deployment becomes production; later ones wait as previews. None can be edited.
POST /api/v1/projects/:slug/promote
expect names the version you believe production is, so two promotions cannot both win. A rollback is a promotion back.
POST /api/v1/projects/:slug/evaluate
Answers go in, a decision comes out, and the whole exchange is written down: input, answers, the clause that fired, a trace.
jev deploy support policy.json --note "tightened the refund rule"
jev promote support 4 --expect 3 --gate 0.05
jev logs support --limit 20
jev open support
Managed state, same interface
jc.journal(project) is a Journal, the interface redisJournal and dbJournal already satisfy. Hand it to makeDispatcher and idempotency, cooldowns, budgets and scheduled work are shared by every instance, with no Redis of your own to run. Read the section.
import { makeDispatcher, dispatch } from 'jevlang/dispatch';
const dispatcher = makeDispatcher(handlers, { journal: jc.journal('support') });
await dispatch(dispatcher, state, decision, { key: caseId });
Actions on your own machine
Make a target { "type": "runner", "pool": "prod-east" } and dispatch queues the step instead of calling out. runner() from jevlang/cloud, or jev runner prod-east handlers.json, pulls it with a run-scoped key and reports onto the trace, so a private database or a shell is in reach and nothing on your network accepts a connection. Read the section.
import { runner } from 'jevlang/cloud';
runner({
key: process.env.JEV_RUNNER_KEY, // a key with the run scope
pool: 'prod-east',
handlers: {
'billing-queue': async (state, decision) => ({ ticket: await fileTicket(decision) }),
},
}).start(); // claim, run, report; stop() finishes the jobs in hand
Bring your own everything
Model keys, the model endpoint, state, traces and handlers each have a managed and a bring-your-own option, chosen per environment on the same code path: your own Anthropic key, any OpenAI-compatible URL, your Upstash or Postgres, your own runner beside http and webhook. Usage on your own key is never marked up, and the hard spend cap still applies to it.
| Piece | Managed | Bring your own |
|---|---|---|
| Model keys | AI Gateway on the deployment's OIDC token, so we hold no gateway key | Your OpenAI, Anthropic or AI Gateway key, sealed as a secret. Never marked up |
| Model endpoint | AI Gateway | Any OpenAI-compatible URL, including a self-hosted one |
| State (journal, limits, budgets) | Our Upstash, namespaced per tenant | Your Upstash, or your Postgres |
| Traces | Our Postgres, retention by plan, redaction on write | Your Postgres, or nothing at all |
| Handlers | http and webhook behind an egress allowlist, code in Vercel Sandbox | Your own runner: { "type": "runner" } queues the step, and jev runner runs it on your machine |
Tenants isolated by construction
The organization comes from the Authorization: Bearer jev_live_… key, never from the path, so a wrong tenant gets the same 404 an unknown project does. Row-level security on org_id is the second wall, and the negatives are tested.
| Guarantee | How it holds |
|---|---|
| The tenant comes from the credential | Every store function takes the organization first; a wrong tenant is the 404 an unknown slug gets. |
| Every Redis key and every journal name is prefixed | t:{org}:p:{project}:e:{env}: server-side, so a project's configuration cannot reach another namespace — and two tenants' identical Idempotency-Keys are not the same case. |
Row-level security on org_id is the second wall | The app connects as the database's owner and drops to a role with no BYPASSRLS inside every transaction, naming the organization it is reading. A query that forgets reads nothing rather than everything. |
| The negatives are tested | org B's key reads, writes, limits, spends and promotes nothing of org A's. |
One HTTP API
Every route sits under /api/v1, and a key carries the scopes evaluate, dispatch, deploy or read. Idempotency-Key makes a retry return the first answer instead of paying or acting twice, and a jev_pub_… key is safe in a browser behind an origin allowlist.
The API
| Route | What it does |
|---|---|
POST /api/v1/projects | create a project, with its three environments |
GET /api/v1/projects | this organization's projects |
GET /api/v1/projects/:slug | one project, its environments and what each serves |
PUT /api/v1/projects/:slug | replace one environment's configuration |
POST /api/v1/projects/:slug/deployments | publish a policy artifact — the first becomes production, later ones move dev and preview |
GET /api/v1/projects/:slug/deployments | newest first |
POST /api/v1/projects/:slug/promote | {deployment, expect} moves production; {deployment, gate} replays real traces first and refuses when too much changed |
POST /api/v1/projects/:slug/replay-diff | production's recent traces decided again against another deployment; a trace whose inputs cannot be rebuilt is counted as refused, never skipped |
POST /api/v1/projects/:slug/evaluate | ask the model the policy's questions, then decide |
POST /api/v1/projects/:slug/decide | decide on answers you already have; no model is asked |
POST /api/v1/projects/:slug/dispatch | decide, then run the project's handlers (dry_run rehearses) |
POST /api/v1/projects/:slug/state | the project's managed journal: steps, cooldowns, budgets, scheduled work |
GET /api/v1/projects/:slug/traces[/:id] | what happened, newest first |
POST /api/v1/projects/:slug/traces/:id/replay | one trace decided again, against another deployment |
GET/PUT/DELETE /api/v1/projects/:slug/secrets[/:name] | names, versions and a hint; values never come back |
GET/PATCH /api/v1/projects/:slug/escalations[/:id] | the review queue; a resolution becomes a labeled fixture |
GET/POST /api/v1/projects/:slug/fixtures | what the project keeps to replay |
GET /api/v1/projects/:slug/docs/:file | generated per deployment: openapi.json, llms.txt, AGENTS.md, mcp.json, snippets.md |
GET /api/v1/usage | rows, and a total that separates money from estimates |
GET /api/v1/audit | what changed, and who did it |
POST /api/v1/runners/claim | a runner asks its pool for work, with a lease and a one-time claim token; long-polls up to 25 s |
POST /api/v1/runners/jobs/:id/complete | the runner's result, onto the trace that queued it; fail re-queues until five attempts, extend is the heartbeat |
GET /api/v1/runners | pools, their runners and when each last asked |
GET /api/v1/projects/:slug/runner-jobs | a project's runner jobs, newest first |
POST /api/v1/stripe/webhook | the only writer of subscription state: Stripe signs, the raw body is verified, and the projection is idempotent |
POST /api/public/evaluate | the browser door: a publishable key, an origin allowlist, an end-user limit |
Free to start
Signing in at cloud.jevlang.sh creates an organization you own; invite people with one of six roles. The plans are defined once, in a config the /pricing page, the limits and the tests all read.
| Plan | Price | How you pay |
|---|---|---|
| Free | $0 | No card; the free limits apply |
| Pro | $24/seat/month | Stripe-hosted Checkout at the member count |
| Team | — | a conversation and a mailto: |