Get started
Console & projects
The fastest path: create an account in the console, create a project, and you have a published policy and an API key in about thirty seconds.
A project is an isolated policy workspace — a tenant with its own policies, bundles, audit trail, and keys. Creating one gives you:
- A starter policy, already published (owners get everything,
viewers read) - An API key with
check+policy:readscopes — shown once, stored only as a hash - A playground to run real requests against your policy and read the trace
Point the console at your server
The console is a static page that talks to your Verdict server — there's no Verdict-hosted backend, and no data leaves your deployment. Start a PDP with CORS enabled and put its URL in the console's Control plane field:
// your server const { app } = createApp({ store, cors: { origin: "https://verdict.yourcompany.com" }, // where the console is hosted // accounts: false // ← disable signup entirely for machine-only deployments });
$ npx tsx examples/remote-pdp-node/server.ts # dev: CORS "*" , http://localhost:8787
The same flow over HTTP
Everything the console does is public API — script it if you prefer:
$ curl -s $PDP/v1/auth/signup -H 'content-type: application/json' \ -d '{"email":"you@company.com","password":"at-least-8-chars"}' {"user":{…},"sessionToken":"vs_…"} $ curl -s $PDP/v1/projects -H "Authorization: Bearer vs_…" \ -H 'content-type: application/json' -d '{"name":"My SaaS App"}' {"project":{"id":"…","slug":"my-saas-app-3ddf83"}, "apiKey":"vk_live_…", ← copy it now; never shown again "starterPolicyId":"…"}
vs_…) are for humans; API keys
(vk_…) are for your services. Sessions are never
admin-scoped and can only reach projects you're a member of — asking about
someone else's project returns 404, not 403./v1/auth/signup is open by design. If your deployment
isn't meant to be self-serve, set accounts: false, or gate
signup at your edge (invite codes, allowlist, Turnstile) — unauthenticated
flood protection is the edge's job, not the PDP's.Five-minute quickstart
Prefer code to clicks? Skip the console — Mode 1 is just a library call, no server or database involved.
You'll compile a policy, make a decision in-process, and ask Verdict to explain a denial. No server, no database — Mode 1 (embedded) is just a library call.
1 · Install
$ npm install @gomagentic/verdict-engine @gomagentic/verdict-dsl
Add @gomagentic/verdict-sync for hybrid mode,
@gomagentic/verdict-sdk for the unified client, or
@gomagentic/verdict-server to host your own PDP. ESM,
typed, no runtime dependencies in the decision path.
2 · Write a policy
// leave.vpl — deny beats allow; no matching rule means DENY resource "leave" { deny "blocked-countries" { actions = ["*"] condition = user.country in ["KP", "IR"] } allow "hr-full" { roles = ["hr"] actions = ["*"] } allow "manager-approves" { actions = ["approve", "reject"] condition = user.id == resource.attr.managerId } }
3 · Decide
import { allows } from "@gomagentic/verdict-core"; import { compileOrThrow, sealBundle } from "@gomagentic/verdict-dsl"; import { Verdict } from "@gomagentic/verdict-engine"; const { program } = compileOrThrow([{ path: "leave.vpl", source, format: "vpl" }]); const bundle = await sealBundle(program, { tenantId: "acme", bundleVersion: 1 }); const verdict = await Verdict.fromBundle(bundle); const decision = verdict.check({ principal: { id: "mgr-7", roles: ["employee"], attr: { country: "DE" } }, resource: { kind: "leave", id: "lv-42", attr: { managerId: "mgr-7" } }, actions: ["approve", "delete"], }); allows(decision, "approve") // true — manager-approves matched allows(decision, "delete") // false — nothing matched: default deny
4 · Ask why
const why = verdict.explain({ ...request, actions: ["approve"] }); why.results.approve.reason // "MISSING_ATTRIBUTES" why.trace.missingAttributes // ["user.attr.country"] — fix your PIP, not your policy
Or run the finished version: npx tsx examples/embedded-node/main.ts
Run the control plane
The PDP adds what a library can't: policy storage, drafts, publishing, versioning, rollback, audit, API keys, and a bundle-sync endpoint. It's one process (Hono — the same app runs on Node, Workers, Lambda, Bun).
$ npx tsx examples/remote-pdp-node/server.ts storage: sqlite (./verdict.db) Verdict PDP listening on http://localhost:8787
Bootstrap a tenant, a policy, and a key (the example server pre-creates
tenant acme with a published policy; admin token defaults to
dev-admin-token):
$ curl -s http://localhost:8787/v1/check?tenant=acme \
-H "Authorization: Bearer dev-admin-token" \
-d '{"principal":{"id":"hr-1","roles":["hr"]},
"resource":{"kind":"leave","id":"lv-9"},
"actions":["approve"]}'
{"results":{"approve":{"effect":"ALLOW","policyName":"leave","ruleName":"hr"}}, …}
# issue a production key (secret is shown exactly once)
$ curl -s http://localhost:8787/v1/api-keys \
-H "Authorization: Bearer dev-admin-token" \
-d '{"tenantId":"<id>","name":"my-service","scopes":["check"]}'
The full lifecycle is HTTP too: PUT /v1/policies/:id (draft, with
If-Match optimistic concurrency), /validate,
/simulate, /publish, /rollback — see the
API reference.
The dashboard
$ npm run dev --workspace @gomagentic/verdict-dashboard # http://localhost:5180, proxies /v1 to :8787
Open Settings, leave the server URL empty (dev proxy), paste a
token, set the tenant. Then: edit policies with line-precise diagnostics,
simulate drafts against real requests with the full rule-by-rule
trace, publish, roll back from version history, browse the audit log,
and manage tenants and keys. Production builds are static files:
npm run build --workspace @gomagentic/verdict-dashboard.
Integrate
Choosing a mode for your project
| Your situation | Use | Decision latency |
|---|---|---|
| JS/TS service, policies change occasionally | Embedded — compile at build/boot, ship the bundle | ~12 µs, zero network |
| Polyglot services, central governance | Remote PDP — one HTTP call | network + ~0.5 ms |
| Edge/serverless + policies managed centrally | Hybrid — local eval, background sync | ~12 µs, sync off-path |
All three run the same bundle and return the same decisions — you can start remote and move hot paths to hybrid later without touching policy.
Node / API middleware
The pattern for any HTTP framework: derive the principal from your auth, name the resource and action, deny by default. With Express and the hybrid client:
import { SyncedVerdict, HttpBundleSource, PollingTransport } from "@gomagentic/verdict-sync"; const authz = await SyncedVerdict.start({ source: new HttpBundleSource({ baseUrl: env.PDP_URL, token: env.VERDICT_KEY }), transport: new PollingTransport({ intervalMs: 30_000 }), publicKeyJwk: JSON.parse(env.VERDICT_PUBKEY), // refuse unsigned bundles }); // express middleware: guard(resourceKind, action) const guard = (kind, action) => (req, res, next) => { const decision = authz.check({ principal: { id: req.user.id, roles: req.user.roles, attr: req.user.attrs }, resource: { kind, id: req.params.id ?? "*", attr: { ownerId: req.params.owner } }, actions: [action], context: { ip: req.ip }, }); if (decision.results[action]?.effect !== "ALLOW") { return res.status(403).json({ error: "forbidden", reason: decision.results[action]?.reason }); } next(); }; app.post("/leave/:id/approve", guard("leave", "approve"), handler);
ownerId, department, …) before calling
check — Verdict treats missing attributes as
unknown, which never grants. explain() tells you exactly
which attribute was missing.Cloudflare Workers (hybrid)
Workers have no background timers, so the idiomatic pattern is
stale-while-revalidate: serve from the cached engine, refresh via
ctx.waitUntil. Full source in
examples/hybrid-worker/.
let synced = null; // module scope: survives per-isolate export default { async fetch(request, env, ctx) { synced ??= await SyncedVerdict.start({ source: new HttpBundleSource({ baseUrl: env.PDP_URL, token: env.KEY }), publicKeyJwk: JSON.parse(env.PUBKEY), stalenessPolicy: "fail-static", // or "fail-closed" for strict surfaces }); ctx.waitUntil(synced.safeRefresh()); // never blocks the request const decision = synced.check(toCheckRequest(request)); // … 403 on deny, else proceed } };
The staleness policy is your most important knob:
fail-static keeps serving the last good bundle if the control
plane is down; fail-closed denies everything with
STALE_BUNDLE after maxStalenessMs. Choose per
decision point.
Any language (REST)
The wire contract is small, JSON-only, and stable. From Python:
import requests r = requests.post("https://pdp.internal/v1/check", headers={"Authorization": "Bearer vk_live_..."}, json={ "principal": {"id": "u1", "roles": ["analyst"]}, "resource": {"kind": "report", "id": "r9", "attr": {"level": 2}}, "actions": ["read"], }) allowed = r.json()["results"]["read"]["effect"] == "ALLOW"
Batch up to 5,000 checks per call with
POST /v1/check/batch. Client rules that make any port correct:
treat non-ALLOW as deny, retry only network errors and 5xx
(never 4xx), and pass a stable requestId if you want to join
your logs to Verdict's audit trail. The reference implementation is
packages/sdk-js/src/remote.ts (~150 lines).
The JS/TS SDK
One interface across every mode — application code never knows where decisions happen:
import { EmbeddedClient, RemoteClient } from "@gomagentic/verdict-sdk"; // pick one at wiring time: const client = new RemoteClient({ baseUrl, token }); // Mode 2 const client = new EmbeddedClient(engineOrSyncedVerdict); // Modes 1 & 3 await client.authorize({ principal, resource, action: "read" }); // boolean await client.check(request); // full Decision await client.explain(request); // Decision + trace await client.simulate(policyId, request, { useDraft: true }); // dry-run drafts
Reference
Policy language in one screen
| Construct | Example |
|---|---|
| Resource policy | resource "doc" { … } — rules for one resource kind |
| Allow / deny rules | allow "name" { roles = […] actions = […] condition = … }; deny always wins |
| Actions & roles | exact, "*", globs "doc:*", regex "re:^exp_[0-9]+$", groups via action_group |
| Conditions | user.department == resource.department && user.level >= 3 — typed, no coercion |
| Context | context.time.hour, ipInRange(context.ip, "10.0.0.0/8") — deterministic per request |
| Derived roles | derived_role "owner" { condition = user.id == resource.attr.ownerId } |
| ReBAC | related(user.id, "member", resource.attr.teamId) against your relationship graph |
| Quantifiers | all(resource.attr.approvals, a, a.signed == true) |
| Reuse | constants { … }, variables { … }, import "common/roles" as c |
| Scopes | scope = "acme.emea" — most-specific-first chains, tenant overrides |
| Custom functions | extern riskBand(double): string, registered host-side, sandboxed |
Full grammar and semantics: docs/spec/policy-language.md in the
repo. Safety rails everywhere: expression depth 32, 65,536-instruction
budget per check (exceeded ⇒ deny), validated-regex subset, missing
data evaluates to unknown and never grants.
HTTP API at a glance
| Route | Scope | Does |
|---|---|---|
POST /v1/check · /check/batch · /explain | check | Decide; batch up to 5,000; decide with trace |
GET /v1/bundles/latest · /:version · /watch | check | ETag-aware bundle sync + SSE publish events |
POST/GET/PUT/DELETE /v1/policies… | policy:read/write | CRUD with If-Match revisions |
/validate · /simulate · /publish · /rollback · /versions | mixed | The policy lifecycle |
/v1/tenants · /v1/api-keys · /v1/audit · /v1/metrics | admin / policy:read | Administration & observability |
Auth: API keys (vk_live_…) or OIDC bearer tokens; errors are
{"error":{"code","message","issues"}} with a stable code taxonomy.
Full reference: docs/spec/rest-api.md.
Production deployment
| Piece | How |
|---|---|
| PDP + control plane | docker build -f deploy/docker/Dockerfile or helm install verdict deploy/helm/verdict. Postgres required for >1 replica. |
| Secrets | VERDICT_ADMIN_TOKEN (rotate out), VERDICT_SIGNING_KEY, DATABASE_URL, optional OIDC + rate limits |
| Edge decision points | deploy/terraform/cloudflare/ + examples/hybrid-worker/ |
| Dashboard | static build behind your SSO; point it at the PDP |
| CI | .github/workflows/ci.yml: Node matrix, live-Postgres job, dashboard build, benchmarks |
docs/guides/deployment.md.