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:read scopes — 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":"…"}
Sessions (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.
Before exposing signup publicly: /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 situationUseDecision latency
JS/TS service, policies change occasionallyEmbedded — compile at build/boot, ship the bundle~12 µs, zero network
Polyglot services, central governanceRemote PDP — one HTTP callnetwork + ~0.5 ms
Edge/serverless + policies managed centrallyHybrid — 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);
Load resource attributes your conditions need (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

ConstructExample
Resource policyresource "doc" { … } — rules for one resource kind
Allow / deny rulesallow "name" { roles = […] actions = […] condition = … }; deny always wins
Actions & rolesexact, "*", globs "doc:*", regex "re:^exp_[0-9]+$", groups via action_group
Conditionsuser.department == resource.department && user.level >= 3 — typed, no coercion
Contextcontext.time.hour, ipInRange(context.ip, "10.0.0.0/8") — deterministic per request
Derived rolesderived_role "owner" { condition = user.id == resource.attr.ownerId }
ReBACrelated(user.id, "member", resource.attr.teamId) against your relationship graph
Quantifiersall(resource.attr.approvals, a, a.signed == true)
Reuseconstants { … }, variables { … }, import "common/roles" as c
Scopesscope = "acme.emea" — most-specific-first chains, tenant overrides
Custom functionsextern 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

RouteScopeDoes
POST /v1/check · /check/batch · /explaincheckDecide; batch up to 5,000; decide with trace
GET /v1/bundles/latest · /:version · /watchcheckETag-aware bundle sync + SSE publish events
POST/GET/PUT/DELETE /v1/policies…policy:read/writeCRUD with If-Match revisions
/validate · /simulate · /publish · /rollback · /versionsmixedThe policy lifecycle
/v1/tenants · /v1/api-keys · /v1/audit · /v1/metricsadmin / policy:readAdministration & 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

PieceHow
PDP + control planedocker build -f deploy/docker/Dockerfile or helm install verdict deploy/helm/verdict. Postgres required for >1 replica.
SecretsVERDICT_ADMIN_TOKEN (rotate out), VERDICT_SIGNING_KEY, DATABASE_URL, optional OIDC + rate limits
Edge decision pointsdeploy/terraform/cloudflare/ + examples/hybrid-worker/
Dashboardstatic build behind your SSO; point it at the PDP
CI.github/workflows/ci.yml: Node matrix, live-Postgres job, dashboard build, benchmarks
Before production: enable bundle signing and distribute the public key; choose fail-static vs fail-closed per decision point; schedule audit-partition rotation; put fleet-level rate limits at your edge. The complete checklist lives in docs/guides/deployment.md.