SKILL.md
Rekor — Data Layer for AI Agents
You have access to the rekor CLI — the builder interface for Rekor. Use it to set up schemas, configure record_types, test in preview environments, and promote to production. Production agents use MCP tools to read/write records; external systems integrate via REST API. The CLI is your tool for designing and managing the data model.
This file is self-contained on concepts: The Rekor Object Model defines every primitive, the Task → Feature Map routes any request to the section that implements it, and Where to Find More Detail says when to open the bundled references/ files for full option grammar.
Agent Guidelines
- Drive Rekor through the
rekorCLI when you have shell access. If you also havemcprekor*tools in your toolset, prefer the CLI for setup and configuration; reserve MCP tools for production read/write operations. - Only provide information from this skill, tool descriptions, or reference documentation. Do not invent URLs, paths, commands, or flags.
- **Read the matching
references/*.mdfile before configuring its feature. This file deliberately carries the concepts and common command shapes only; the full option grammar for querying/search tuning, external sources, MCP Factory Actions/toolsets, executors, and provider adapters lives in the bundled reference files (index in Where to Find More Detail**). - Schema work (record_types, inbound webhooks, triggers, MCP Factory toolsets) only happens in preview bases. Always create or use a preview before changing schemas.
- Identifiers are permanent. The
idof a base, recordtype, relationship type, Action, and MCP Factory toolset is its identity and is immutable — chosen once at creation and never renamed. Display names and descriptions stay editable, but never theid(a relationship type has no separate name — itsidis also its label). There is no rename: to "rename" one of these you create a new entity and migrate its data (the old one is left untouched). So choose clear, stable, lowercase-slug ids up front (e.g.patients,treatedby). - Promotion is human-only. When schema is ready, surface the exact
rekor bases promotecommand and wait for the user to run it. - Tokens are shown only once on creation. Always tell the user to copy and store it before doing anything else.
- Never auto-commit. Show the user
git diff(when working with schema files) and wait for approval.
The Rekor Object Model
Read this once — it is the complete concept set. Every setup task is a combination of these pieces; the Full Command Reference below gives the commands and rules, and the bundled references/ files the deep grammar.
Organization ← account boundary; API tokens, vault secrets
└── Base (production | preview) ← top-level data container: one per app, domain, or tenant
├── CONFIG — edit in PREVIEW, ship with PROMOTE (human-run):
│ record types (+ their external sources) · relationship types · file types
│ · triggers · inbound webhooks · Actions · toolsets · seed fixture definitions
└── DATA — read/write on ANY base, production included:
records · relationships · files · attachments · batch writes
(seed apply/reset/clear and seed lease acquire/release run only on previews)
The one rule that routes every command: config writes are rejected on a production base — make them in a preview (rekor bases create-preview), then have the user run rekor bases promote. Ordinary data writes work anywhere; seed fixture and seed lease data operations are preview-only. If a write unexpectedly fails with a preview/403 error, check whether it is config or eval-state work on production.
Every primitive, one line each
Two schema→instance pairs anchor the model: record type → record and relationship type → relationship. Files repeat the pattern (file type → file).
| Primitive | What it is | Commands / section |
|---|---|---|
| Organization | Account boundary owning bases, tokens, and secrets; a rec key is scoped to exactly one and cannot be pointed at another (--org / REKORORG are inert) |
— |
| Base | Top-level data container. production (its config entities change only via promote; metadata like name/tags/timezone stays editable) or preview (<prod>--<slug>, a config-editable clone that promotes back to its origin) |
rekor bases |
| Record type | JSON Schema defining a record type — created at runtime, no migrations. Carries schema hints (x-fk foreign keys, x-search tuning, x-archival mode, x-timezone, x-ui display) and optionally declares external sources |
rekor record-types |
| Record | JSON row conforming to its record type. System-generated id plus your externalid/externalsource for idempotent upsert (re-upsert updates in place). Can be cancelled (first-class state) or archived (terminal/inactive) |
rekor records |
| Relationship type | Config defining a rel_type (its id IS the type) with an optional metadata schema and source/target record-type allowlists. Must exist before any relationship of that type |
rekor relationship-types |
| Relationship | Typed, directed link between two records (an endpoint can also be a file), with validated metadata; traversable in both directions | rekor relationships, rekor query-relationships |
| Filter DSL | The one condition language — {field, op, value} + and/or groups — used by records query, trigger --filter, and Action precondition |
Records → Filtering & search |
| File type | Bucket config for files: max size, allowed content types, optional metadata schema | rekor file-types |
| File | Versioned, path-addressed content (PDFs, images, documents). Compare-and-set writes, per-version history and diff, S3-mountable | rekor files |
| Attachment | Simple record-scoped upload — Files are the richer, versioned evolution | rekor attachments |
| External source | Declared on a record type: backs its CRUD operations with an external API through one bidirectional field_mapping contract |
External Sources |
| Trigger | Fires on record/relationship/file events. Its action: signed webhook out, internalwrite (guarded patch to a related record), or externalwrite (push the change upstream through a source) | rekor triggers |
| Inbound webhook | Signed ingest URL an external system pushes to — payload stored raw, translated via a mapping, or hydrated (thin "id changed" notification → Rekor fetches the full record through a bound source) | rekor inbound-webhooks |
| Executor | A small HTTP service you deploy for what Rekor can't call directly (custom logic, mTLS, SOAP, binary creds); receives signed dispatches, verified with @wayai/executor-sdk |
Executors |
| Signing v1 | The HMAC request signature on trigger deliveries, executor-bound source calls (opt-in signing), and inbound-webhook verification (the default scheme) — one wire format, both directions; @wayai/executor-sdk implements it, never hand-roll |
Executors, Inbound Webhooks |
| Action | One record type + one operation, shaped and guarded (filterablefields, writablefields, precondition, binding) — or a composite of atomic multi-record steps. Its id is the agent-facing tool name |
rekor actions |
| Toolset | A curated MCP server composed of Action references (plus relationship/batch/SQL tools), served at mcp.rekor.pro/t/<slug>/mcp |
rekor toolsets |
| Seed fixture / lease | Named, reversible records+relationships baseline for hermetic agent evals. Definitions are config; apply/reset/clear are preview-only data operations. An actor-bound lease exclusively resets, owns, and clears fixture state for one eval run |
rekor seed, rekor seed lease |
| API token | rec… credential: grant-scoped (bases × recordtypes × environments × permissions) or toolset-bound (its authorization IS one toolset's tool surface) |
rekor tokens |
| Secret | Org-level encrypted credential (string or file blob), referenced from source/trigger config as vault:<name>, pullable by executors at dispatch |
rekor secrets |
Identity rules. Config slugs are permanent ids (see Agent Guidelines). Always set external_id on records that have a natural key — retries and re-imports then upsert instead of duplicating. Every record write is validated against its record type schema (plus x-fk reference checks and datetime canonicalization).
Integration edges. One fieldmapping contract on a source is reused by every edge — proxied reads/writes, externalwrite out, inbound-webhook mapping/hydration in. Model entities canonically first, then pick edges per operation: the Integration Modeling section is the decision guide and pattern catalog.
Consistency & limits. Single-record gets and eligible active-record records query / query-relationships lists reflect writes immediately; sql, search, and a few query shapes that read from recently-synced data (filters on the built-in createdat/updatedat timestamps without a timezone offset) may lag a write by a moment — and on a local-tier base (the default for an eval clone; see Bases) those same shapes are refused outright rather than lagging. Record/relationship JSON is capped at ~1 MiB — large or binary content belongs in Files.
Task → Feature Map
Route any request to the right feature, then jump to that section of the command reference.
| You need to… | Use | Section |
|---|---|---|
| Store & validate structured business data | record type schema + records | RecordTypes, Records |
| Change one field without resending the record | partial update (REST PATCH / MCP patch) |
Records |
| Prevent duplicates on retries and re-imports | upsert by --external-id |
Records |
| Link entities; traverse a graph | relationship type + relationships | Relationship Types, Relationships |
| Make a reference resolve to a real record | x-fk on the schema field |
Records → Referential integrity |
| Find by approximate name/text | Filter DSL search operator |
Records → Filtering & search |
| Reports, aggregations, joins | rekor sql |
SQL Query |
| Store PDFs / images / versioned documents | file type + files; mount over S3 | Files |
| Several writes, all-or-nothing | rekor batch |
Batch |
| Load historical data in bulk (backfill) | rekor import run (NDJSON; streams, resumes) |
Bulk Import |
| Notify an external system on data change | trigger → webhook | Triggers |
| "When A changes, update related B" | trigger → internal_write (async or transactional) |
Triggers |
| Mirror native records out to an upstream API | trigger → external_write reusing a source |
Triggers, External Sources |
| Accept full-payload pushes from outside | inbound webhook (+ optional field mapping) | Inbound Webhooks |
| Turn "record X changed" pings into full records | hydrating inbound webhook (+ merge to keep your fields) |
Inbound Webhooks |
| Read/write an external API as if it were native | external sources on the record type | External Sources |
| Integrate something Rekor can't call directly | executor + @wayai/executor-sdk |
Executors |
| Give an LLM agent safe, domain-named tools | Actions + toolset + toolset-bound token | MCP Factory |
| Make a state-dependent write race-free | Action precondition (compare-and-set) |
MCP Factory |
| One agent tool = several writes, atomic | composite Action | MCP Factory |
| Least-privilege machine/CI access | scoped tokens (create-for-toolset for agents) |
API Tokens |
| Store integration credentials | secret vault (vault:<name> references) |
Secrets |
| Manage config in git / review in PRs | rekor pull / rekor push |
Config as Code |
| Deterministic agent evals, no live upstream | preview with --integrations disabled (+ --analytics local for disposable data) + seed fixtures |
Bases, Seed Fixtures |
| Import/export provider tool definitions | provider adapters | Provider Adapters |
| See who changed what, and when | history subcommands (admin-gated) |
Records |
| Decide native vs proxy vs mirror backing | the pattern catalog | Integration Modeling |
| Shape schemas/tools so agents succeed | principles + recipes | Modeling Principles |
| Design an agent-facing tool surface | tool design principles (intent tools, required params, closed sets) | Modeling Principles |
Where to Find More Detail
The bundled references/ files carry the full option grammar — read the matching one before configuring that feature.
| Reference file | Read when | Contents |
|---|---|---|
references/querying.md |
Writing non-trivial SQL, tuning search, setting timezones |
SQL example gallery (arrays, ARRAY JOIN, CTEs, --param, fuzzy ranking), x-search modes/threshold, datetime & timezone configuration |
references/external-sources.md |
Configuring any external source | Full source grammar: fieldmapping rules (value maps, transforms, split/destructure/computed, per-op overrides), per-op endpoints, named write bindings, idpath variants, forwardfilters, localfilters, body shaping, {{current.}}/{{prior.}}, caching/signing/timeout/breaker, SSRF rules, a worked legacy-upstream example |
references/mcp-factory.md |
Authoring Actions or toolsets beyond the defaults | Full Action + toolset grammar: filterablefields (incl. optional), expose/default, writablefields, base_filter, precondition, binding, composite Actions, tool naming, which base serves a toolset slug |
references/executors.md |
Building or deploying an executor | The @wayai/executor-sdk contract (verify, dedupe, error envelope), signed write-back, where to host, the vault certificate pull pattern, local dev, retries |
references/providers.md |
Importing/exporting provider tool definitions | Exact rekor providers command forms per provider (OpenAI/Anthropic/Google/MCP) |
At runtime you can also consult:
rekor <command> --help(andrekor <command> <subcommand> --help) — the authoritative flag list for any command.rekor status— auth, connectivity, active org, and worktree binding;rekor whoami— the authenticated identity.
If a command, flag, or URL appears in none of these sources, assume it does not exist — never guess one.
First-Time Setup Walkthrough
When the user is starting from scratch, walk them through these steps. Skip any that are already done — check with rekor bases list before assuming.
1. Install the CLI
npm install -g rekor-cli
Verify with rekor --version. The CLI is published to npm as rekor-cli; the binary is rekor.
2. Authenticate
Authenticate with an API key:
rekor login --token rec_xxx
An API key is the only credential this API accepts. Browser sign-in (rekor login with no --token) is retired: the backend no longer verifies any browser or OAuth credential, so a session obtained that way is rejected on every subsequent command. Get a key from whoever manages the account — the platform that owns your organization issues them, and each key is already scoped to exactly one organization.
rekor init is retired with it. It existed to bind a repo to one of your organizations, which required a user login to enumerate them; a rec_ key carries its organization already, so there is nothing to choose.
The organization is the key, and nothing overrides it. An existing committed .rekor.yaml, the --org flag and REKORORG all feed one request header the server no longer reads, so they change nothing — passing --org for an organization your key was not minted in operates on the key's own organization and reports no error. To work in a different organization, use a key minted there (REKORTOKEN, or rekor login --token).
If the user has no organization yet, they get one from the platform that manages their account — self-serve signup here is retired. The free plan includes 5,000 operations per month.
3. Create a base
Bases are top-level data containers. One per app, domain, or tenant.
rekor bases create <id> --name "<Display Name>" [--tags <comma-separated>]
Set REKOR_BASE=<id> in the user's shell to avoid passing --base on every command.
Production bases require a paid plan. On the free plan this command fails with an upgrade message; create a preview base instead and do all the work there:
rekor bases create <id> --name "<Display Name>" --environment preview
Everything else (record_types, records, relationships, triggers, toolsets) behaves identically — it is a complete development environment.
A preview created this way has no production origin, and an origin can't be attached later (the API rejects the attempt rather than silently ignoring it), so it can't be promoted directly. After upgrading, stand production up beside it and copy the config across:
rekor pull <preview-id> # config → rekor-ws/bases/<preview-id>/
rekor unbind # release this checkout's binding to it
rekor bases create <prod-id> --name "<Display Name>" # allowed once on a paid plan
rekor bases create-preview <prod-id> --name dev # a preview linked to production
rekor pull <prod-id>--dev # scaffolds rekor-ws/bases/<prod-id>--dev/
# copy ALL seven entity folders — record-types/, relationship-types/, inbound-webhooks/,
# triggers/, toolsets/, actions/, seeds/ — from rekor-ws/bases/<preview-id>/
# into rekor-ws/bases/<prod-id>--dev/ (push is additive, so anything you skip is
# silently absent rather than reported as a removal)
rekor push <prod-id>--dev
rekor bases promote <prod-id> --from <prod-id>--dev
Two details that make the copy a file copy rather than an id edit: push <selector> finds the folder named <selector> (editing base.yaml alone won't redirect it), and pull/push bind a checkout to the base they touched — hence rekor unbind before switching. Records and relationships are data, not config: they stay in the original preview and don't move with pull/push.
rekor bases promote also requires a paid plan. A production base's reads and writes never stop — only deploying new config to it does, and --dry-run and rekor bases rollback stay available regardless.
4. Create a preview environment
Schema changes (record_types, inbound webhooks, triggers, MCP Factory toolsets) are blocked in production bases. Create a preview to do schema work:
rekor bases create-preview <id> --name "<preview-slug>"
The resulting preview base ID is <id>--<preview-slug>. Use that as --base for all schema commands.
The origin may be a production base or another preview — cloning a preview gives you <preview-id>--<preview-slug>, linked back to the preview it was cloned from.
A preview cloned from another preview is created on the local analytics tier, where rekor sql over records, history and relationship traversal are refused — see Analytics tier under Bases. Pass --analytics standard when cloning a preview origin for schema work you intend to query with rekor sql (step 6); the tier cannot be changed afterwards. Cloning a production origin is unaffected.
If you created <id> as a preview in step 3 (free plan), this step still works, but it buys you little: a preview cloned from a preview promotes through its origin, never straight to production, is created on the local tier described just above, and step 6's promote is paid-plan gated regardless. Simplest is to skip this step and work directly in <id> — steps 5 and 6 are unchanged if you pass --base <id> wherever they say --base <id>--<preview-slug>, which covers step 6's testing commands too. In step 6, skip the promote: there is no production base to promote to.
5. Define the first record_type
A record_type is a JSON Schema that defines a record type. Define it in the preview base:
rekor record-types upsert <slug> --base <id>--<preview-slug> \
--name "<Display Name>" \
--schema '{"type":"object","properties":{...},"required":[...]}'
For schemas longer than a line, write them to a file and pass --schema @path/to/schema.json.
6. Test in preview, then ask the user to promote
Write some test records to the preview, query them, iterate until the schema is right:
rekor records upsert <slug> --base <id>--<preview-slug> --data '{...}'
rekor sql "SELECT * FROM records WHERE org_id = {org_id:String} AND base_id = {base_id:String} AND record_type = '<slug>' AND deleted = false" --base <id>--<preview-slug>
When ready, surface the promotion command for the user to run (you cannot promote yourself):
rekor bases promote <id> --from <id>--<preview-slug>
Recommend --dry-run first to preview the diff.
7. Connect production agents
Two ways to give production agents access:
Scoped API token — for direct REST/CLI access:
rekor tokens create --name "<agent-name>" \
--grants '[{"scope":{"bases":["<id>"],"environments":["production"]},"permissions":["read:records","write:records"]}]'
The raw token (rec_...) is shown once. Copy it immediately.
MCP Factory toolset — for purpose-built MCP tools per agent (recommended for LLM agents):
See the [MCP Factory](#mcp-factory-custom-toolsets) section below.
Beyond the first record type — relationships, files, triggers, integrations, toolsets, evals — use the Task → Feature Map above to find the right feature. Once the config spans more than a couple of entities, prefer Config as Code (rekor pull/push) over one-off commands so changes are reviewable files.
Quick Start
The core schema→instance pairs in action. --base my-ws is a placeholder — for the config writes below (record-types upsert, relationship-types upsert) it must name a preview base.
1. Create a record_type
rekor record-types upsert invoices --base my-ws \
--name "Invoices" \
--schema '{"type":"object","properties":{"customer":{"type":"string"},"amount":{"type":"number"},"status":{"type":"string","enum":["draft","issued","paid"]}},"required":["customer","amount"]}'
2. Create a record
rekor records upsert invoices --base my-ws \
--data '{"customer":"Acme Corp","amount":5000,"status":"draft"}'
With external ID for idempotent upsert:
rekor records upsert invoices --base my-ws \
--data '{"customer":"Acme Corp","amount":5000}' \
--external-id inv_123 --external-source billing
3. Query records
rekor sql "SELECT data.invoice_number.:String as num, data.status.:String as status, arraySum(CAST(data.line_items[].amount, 'Array(Float64)')) as total FROM records WHERE org_id = {org_id:String} AND base_id = {base_id:String} AND record_type = 'invoices' AND deleted = false ORDER BY total DESC" --base my-ws
4. Declare a relationship type, then link records
A relationship type must exist before linking. Declare it once (a config write — do this in a preview environment, then promote):
rekor relationship-types upsert belongs_to --base my-ws \
--description "Invoice belongs to a customer" \
--source-record_types '["invoices"]' --target-record_types '["customers"]'
Then link:
rekor relationships upsert --base my-ws \
--source invoices/rec_abc --target customers/rec_xyz \
--type belongs_to
Omit --schema to allow any metadata; pass --schema to validate the relationship's --data against a JSON Schema.
Each endpoint can also be addressed by externalid instead of the internal record id — handy when you just upserted the records by externalid and never kept the returned ids:
rekor relationships upsert --base my-ws \
--source-external invoices/inv-2026-001 --target-external customers/cust-42 \
--type belongs_to
Exactly one of --source/--source-external per endpoint (same for target); add --source-external-source <name> when the externalid is scoped to a source system. The two modes differ on missing records: an internal id is stored as given (linking before the record exists is allowed), while external addressing requires the record to exist — the write fails with a clear error otherwise. The API mirrors this: sourceexternalid/targetexternalid (+ optional *externalsource) in place of sourceid/target_id.
5. Traverse relationships
rekor query-relationships invoices rec_abc --base my-ws \
--type belongs_to --direction outgoing
Full Command Reference
Destructive
deletecommands prompt for confirmation in an interactive terminal. Pass-y/--yesto skip the prompt (it is also auto-skipped in non-interactive/CI contexts).
Bases
rekor bases list [--tag <tag>]
rekor bases get <id>
rekor bases create <id> --name <name> [--description <desc>] [--tags <comma-separated>] [--environment <production|preview>] [--analytics <standard|local>] # production requires a paid plan
rekor bases rename <id> --name <new-name> # display name only — the id/slug is immutable
rekor bases tag <id> --tags <comma-separated>
rekor bases delete <id> [--purge] # --purge is preview-only: also reclaims storage
rekor bases create-preview <origin-id> --name <preview-slug> [--description <desc>] [--integrations <enabled|disabled>] [--analytics <standard|local>] [--create-only]
rekor bases update <preview-id> --integrations <enabled|disabled> # preview-only eval toggle
rekor bases list-previews <origin-id>
rekor bases promote <prod-id> --from <preview-id> [--dry-run] [--record_types <ids>] [--triggers <ids>] [--inbound-webhooks <ids>]
rekor bases promotions <prod-id>
rekor bases rollback <prod-id> --promotion <promotion-id>
Tags let you group bases (e.g., client:acme,billing). Filter with --tag. Promotion is human-only (see Environments). Promote selectively with --record_types/--triggers/--inbound-webhooks (omit to promote everything). promotions lists prior promotions; rollback reverts one by ID.
create-preview clones from a production base or another preview; the new id is <origin-id>--<preview-slug> and it links to the origin it was cloned from, so a preview of a preview promotes through that origin rather than straight to production. Re-running it re-applies config onto an existing preview and leaves its data alone — pass --create-only to fail instead, which is what you want for a throwaway per-run base where landing on someone else's would corrupt both runs.
Delete vs purge. rekor bases delete <id> is a tombstone: the base stops being listed, but its records, relationships, file metadata and config are retained, and recreating the id restores them. --purge (preview bases only) destroys them permanently. A purged id is then retired — it cannot be recreated, so derive a fresh id for the next run. Use purge to tear down disposable bases; a plain delete leaves everything behind forever.
Two things purge does not remove: uploaded file contents (only the metadata that indexes them), and the base's rows in the analytical store. Neither is reachable once the id is retired, but both still occupy storage.
Eval mode (--integrations disabled). A preview can run with its external integrations disabled (default enabled). When disabled, every external edge goes inert — recordtype sources, outbound externalwrite triggers, and inbound-webhook hydration — and the preview serves its own seeded data with the exact same schema, tools, and field mappings, without calling the external systems. That makes a preview a deterministic, prod-safe target for agent evals: exercise your agent's policy against the same canonical surface without polluting a production-only upstream or hitting live rate limits/PII. Seed it by writing records while disabled (writes to source-backed record_types land locally), flip to enabled to test the real integration, and re-clone from production to stay drift-free. Production bases are always enabled — the toggle is preview-only.
Analytics tier (--analytics). A preview can keep its data out of the analytical store entirely. On local, records and relationships live in the base itself and are never mirrored, which suits a disposable eval base whose data is garbage the moment the run ends.
A preview cloned from another preview is created on local by default — that shape is the per-session eval clone. A preview cloned from a production base, and every base from rekor bases create, defaults to standard. Pass --analytics to choose explicitly on either path; it wins over the default.
Two consequences, and the first is data loss, not a read restriction:
- Change history is not recorded at all. It is discarded, not relocated — no audit row is written anywhere for any write on such a base. The
historysubcommands and the audit endpoints return an error rather than an empty list, but there is nothing behind that error to recover. Never chooselocalfor a base whose history you might want. - Reads that would have come from the analytical store refuse rather than answer empty: raw SQL over the record tables and relationship traversal. Single-record reads and ordinary filter-DSL lists are served from the base itself and work as usual — but a query the base cannot answer on its own falls into the same refusal. That covers the
searchoperator, a filter referencingarchived, a comparison ofcreatedat/updatedatagainst a datetime literal carrying no timezone offset, and any list whose scope holds more live rows than the in-base cap (10,000). That cap is per record_type for records and base-wide for relationships, and it is measured against the whole scope before paging — a smaller--limitdoes not lift it, so size seeded fixtures below it.
Plan eval assertions around exact filters on data.* fields, and give any createdat/updatedat bound an explicit offset (2026-08-01T00:00:00Z).
At a glance — what a local base does not serve, and what to use instead:
Not served on a local base |
Instead |
|---|---|
rekor sql over records, relationships or auditlog (and the toolset sqlquery tool) |
records query with the Filter DSL. Every other table — recordtypes, relationshiptypes, bases, organization, operationslog — still answers, though operationslog holds only billing rows on this tier |
history on any entity, and the audit endpoints |
Nothing — history is never recorded for these bases, so there is no fallback |
Relationship traversal (rekor query-relationships, the toolset relationship-list tools) |
The relationship list surface (GET /v1/{base}/relationships?filter=…), queried by endpoint id |
Filter conditions using search, or referencing archived |
Exact/like conditions on data.*; archived rows are not a separate tier here |
Built-in createdat/updatedat compared to a value with no timezone offset |
Give the bound an offset or Z. Your own data.* datetime fields are unaffected |
| A list whose scope exceeds 10,000 live rows | Keep fixtures under the cap, or use a standard base |
The tier is fixed when the base is created and preview-only. There is no switch in either direction — nothing reconciles the store across a change, so a base minted on the wrong tier can only be replaced (--purge plus a new id, since a purged id is retired). Set it deliberately at creation:
rekor bases create-preview <origin> --name eval-run-42 --integrations disabled --analytics local --create-only
Pass --analytics standard instead for anything whose history or SQL you will want later — including a clone of a preview origin, which defaults to local. MCP managebase createpreview takes the same analytics parameter. create-preview (and rekor push when it auto-creates from a preview originbaseid) prints a warning if the new base landed on local while carrying a toolset that exposes sql_query or a relationship-list tool — those tools refuse there, permanently.
Config as Code (pull / push)
Manage a base's whole config — record_types, relationship types, inbound webhooks, triggers, MCP toolsets, seed fixtures — as version-controlled YAML files instead of one-off commands. Files live under rekor-ws/bases/<db>/, one file per entity, so changes review cleanly in a pull request.
rekor pull <preview> # write the preview's config + a read-only mirror of linked production
rekor push <preview> [--dry-run] # apply your local files to the preview (--dry-run shows the diff only)
rekor push <preview> --prune # also delete entities that exist on the server but not in your files
rekor use <preview> # bind THIS git worktree to a base (push/pull refuse a different one)
rekor unbind # clear this worktree's base binding
- Previews are the only edit target.
pushonly writes preview bases (config is never edited directly in production). When youpulla preview, the linked production config is also written beside it as a read-only reference folder (rekor-ws/bases/<prod>/) so you can see the live production config while iterating — it's marked read-only (pushrefuses it and barepull/pushauto-selection ignores it).pull <production>directly writes only that read-only reference. To ship, runrekor bases promoteas usual. - Auto-create a preview. Scaffold
rekor-ws/bases/<name>/base.yamlwithoriginbaseid: <prod-id>(and nobase_id);rekor pushcreates the preview from that production base, writes the new id back, and applies your files. base.yamldescribes the base, it does not reconfigure it. Alongsidebaseid/originbaseid,pullrecords the base'senvironment,integrationsandanalyticsthere so the folder documents what it targets. For a folder that already has abaseidthese are purely descriptive — editing them changes nothing, and the create-onlyanalyticstier can never be changed this way; treat a surprising value as a signal to re-check the base, not to edit the file. The one exception isanalyticson the auto-create branch: a new folder carryingoriginbaseidand nobase_idforwards its declared tier to the basepushcreates, and that is your only chance to set it there. Declareanalytics: standardbefore the firstpushif the origin is a preview (whose clones default tolocal) and you will want SQL, history or traversal.- Worktree binding (routing guard). Each git checkout (main or linked worktree) can be bound to one base.
pull/pushrefuse to run against a different base once bound — catching the common mistake of a prompt landing in the wrong terminal/worktree and clobbering another preview's config. The binding is set automatically on the first successfulpull/pushinto an unbound checkout (and on creating a new preview), is per-worktree and never committed.rekor statusshows the current binding. Ifpull/pusherrors with a binding mismatch, stop and ask the user before doing anything else — it usually means the prompt was meant for a different worktree. Do not runrekor unbind/rekor usewithout explicit user instruction in the current session; changing the binding is a routing decision, like switching which base the user thinks you're working on. - Secrets are never written to files. Inbound-webhook/trigger and external-source secrets are stripped on
pull. Onpush, a newly added inbound webhook/trigger gets a fresh secret (printed once — save it); existing secrets are left untouched. Manage secret values withrekor inbound-webhooks/rekor triggers/rekor secrets. - Deletions are opt-in. Because deleting a record_type also removes its records,
pushis additive by default: entities missing from your files are reported but kept. Add--pruneto delete them. - Record base context in
AGENTS.md. Onpull/push, Rekor seeds (create-if-absent, never overwriting your edits) anAGENTS.md+ a one-lineCLAUDE.md@AGENTS.mdshim in each base folder (rekor-ws/bases/<db>/). Use the base folder'sAGENTS.mdto capture what the config itself can't: the base's purpose, key decisions and why, business rules, terminology, and integration quirks. Keep it current as the base evolves — it's the durable memory the next agent reads before touching this data layer. If the files are missing (e.g. an older pull), create them yourself in the same shape. - Renaming a recordtype is re-seed, not in-place. A recordtype's
idis its identity and must equal its filename (record-types/<id>.yaml), so renaming means updating both the filename and the innerid:field together (changing only one errors; or delete the innerid:so it defaults to the filename). Either way it's a create-new + orphan-old, not an in-place rename: the diff shows+ <new>/- <old>,pushcreates a new empty recordtype, and the old one (with all its records) is left untouched — records do not migrate. To rename and keep the data: (1)pushto create the new empty recordtype, (2) re-seed its records (e.g.rekor records upsert, or batch), then (3)push --pruneto delete the orphaned old record_type, which cascades its records. The same filename-is-idrule applies to relationship types and the other config entities.
RecordTypes
rekor record-types list --base <ws>
rekor record-types get <id> --base <ws>
rekor record-types upsert <id> --base <ws> --name <name> --schema <json|@file> [--description <desc>] [--icon <name>] [--color <hex>] [--sources <json|@file>]
rekor record-types delete <id> --base <ws>
rekor record-types history <id> --base <ws> [--limit <n>] [--offset <n>] [--diff]
--icon/--color are display hints for the inspection UI. --sources declares external sources — see the External Sources section below.
Deleting a recordtype also removes all of its records (and any relationships pointing at them); a recordtype recreated with the same id starts empty.
Records
rekor records upsert <record_type> --base <ws> --data <json|@file> [--id <uuid>] [--external-id <id>] [--external-source <src>]
rekor records get <record_type> <id> --base <ws>
rekor records query <record_type> --base <ws> [--filter <json|@file>] [--sort <json>] [--fields <list>] [--limit <n>] [--offset <n>] [--external-source <src>]
rekor records delete <record_type> <id> --base <ws>
rekor records history <id> --base <ws> [--limit <n>] [--offset <n>] [--diff]
history returns the full change history for an entity — every version as a snapshot, who changed it, the operation, and when. Admin-only: available to platform admins or a token granted read:audit; ordinary agent tokens are rejected. (Same history subcommand exists on relationships, record_types, and relationship-types.) Change history is not kept on a local-tier eval clone — see Bases → Analytics tier.
Filtering & search. query (REST GET /records/<recordtype>, MCP queryrecords) takes a Filter DSL expression — a condition {field, op, value} or an and/or group of them. Operators: eq, neq, gt, gte, lt, lte, in, notin, like, ilike, isnull, isnotnull, has, and search. This DSL is the only accepted form — Mongo-style shorthand ({"data.field":"value"}, {"field":{"$in":[...]}}) is not supported.
Listing and filtering active records (and relationships) is read-after-write consistent — a record you just wrote appears in the very next list, no lag. A few query shapes instead read from recently-synced data (so a just-written record may take a brief moment to appear): search queries (see below) and filters comparing the built-in createdat/updatedat timestamps to a value written without a timezone offset. (Requesting a projected subset of fields does not by itself change this — projection is never what moves a query onto the recently-synced path.) On a local-tier base — what a preview cloned from another preview is created as, see Bases → Analytics tier — there is no recently-synced copy at all, so both shapes are refused with an error instead of lagging.
search matches a field by approximate value — use it when you have a near-correct string but not the exact stored one (e.g. a model name, a place, a plan name, a SKU with a possible typo). Results come back ranked by closeness, each carrying a searchscore (0–1, higher = closer):
// "find vehicles whose model is close to 'honda civic', only active ones"
{ "and": [
{ "field": "data.status", "op": "eq", "value": "active" },
{ "field": "data.car_model", "op": "search", "value": "honda civic" }
] }
Every field is searchable by default — search needs no setup. Exact filters and search compose in one query: put exact conditions alongside the search leg so the exact ones narrow the set and search ranks within it. To tune how a field is matched — x-search modes (name/text/fuzzy), threshold, and searchable: false — see references/querying.md. Search runs against the latest synced data, so a record written a moment earlier may take a brief moment to appear. To expose search (and its tuning) to an agent through a toolset, declare match: search on a list Action's filterable_fields — that's the only match that reaches this operator.
Sorting. --sort takes a JSON array of terms, each {"field":"data.<field>","direction":"asc"|"desc"} — e.g. --sort '[{"field":"data.created_at","direction":"desc"}]'. Multiple terms sort lexically (first term primary). direction defaults to asc if omitted. The colon shorthand data.name:asc is not accepted — pass the JSON array form. Omit --sort when using search to keep the relevance ranking.
Datetimes. Declare datetime fields in the recordtype schema with "format": "date-time" (or "format": "date" for date-only). Rekor stores them in a single canonical form: a naive value (no offset) gets the recordtype's timezone attached, so 2026-06-11T13:00:00 is stored and returned as 2026-06-11T13:00:00-03:00 — the same shape from both query and a single-record read, with no UTC math required of you. The timezone resolves recordtype x-timezone → base settings.timezone → UTC. Filtering is instant-aware: eq/neq/in/notin and the range operators match datetimes by point-in-time, so the exact representation you pass (T vs space, with or without offset) doesn't change the result. Use eq for an exact datetime, not like (plain substring). Raw rekor sql is not rewritten this way — prefer the Filter DSL query for datetime conditions. Setting the timezone (CLI --timezone, REST, merge-vs-replace settings semantics) is covered in references/querying.md.
Partial vs full updates. A full upsert (managerecord upsert, REST PUT) writes the whole record — send every field. To change just one field (flip a status, set a flag) without re-sending the rest, use a partial update: managerecord patch, the generated update<recordtype> tool, or REST PATCH .../records/<recordtype>/<id>. A partial update merges the provided fields over the stored record (top-level merge; nested objects are replaced wholesale), targets an existing record by id or externalid, and the merged result must still satisfy the recordtype schema. For a recordtype backed by an external source, the provided fields are forwarded to that upstream system, which decides how the update is applied — the merge-and-keep-the-rest guarantee applies to records stored in Rekor.
Cancellation & archival. Records are updatable by default — re-upserting the same externalid updates the existing record in place (idempotent upsert), and you can advance state freely (e.g. scheduled → confirmed). A recordtype can opt into stricter archival via its schema's x-archival.mode: immutable (write-once — any update is rejected) or onattribute (updatable until a configured terminal value). A record can also be cancelled — a first-class state distinct from delete — via MCP (managerecord cancel) or REST (POST .../records/<recordtype>/<id>/cancel); there is no CLI cancel subcommand. A cancelled record can no longer be updated. Records may also be archived automatically (when they reach a terminal/immutable state, or after a long period of inactivity). An archived record stays readable and can still be cancelled, but it can't be deleted, and re-upserting by the same externalid creates a new record rather than updating the archived one. List queries return active records by default — archived records are excluded from rekor records list / rekor relationships list, the query tools, and record traversal. To query archived records through a list, add a filter condition on archived (e.g. {"field":"archived","op":"eq","value":true}) — your condition then decides which tier you see. Record traversal has no filter override — archived links are reachable only via raw SQL. Raw rekor sql applies no default: add archived = false yourself for active-only results (cancelled rows carry cancelled = true).
Referential integrity (foreign keys). An immediate child of the Record type schema's root properties can be declared a foreign key into another recordtype by adding an x-fk hint. The referencing Record type must be native (sources: []). Every native write then checks the value points at a real record — a missing reference is rejected with an actionable error instead of landing as silent bad data. Nested/composed declarations (for example insurance.planid), declarations on a source-backed referencing Record type, and declarations in a Relationship type data_schema are rejected at config write with the exact schema pointer. Hoist a nested reference to a root property, remove x-fk, or make the referencing Record type native. Existing production legacy config keeps serving while it is corrected in a linked preview; promotion dry-run reports blockers across the full resulting config and a real promotion remains blocked until they are fixed.
"patient_id": { "type": "string", "x-fk": { "record_type": "patients" } }, // matches patients by external_id (default)
"professional_id": { "type": "string", "x-fk": { "record_type": "professionals", "key": "code" } }, // matches a named field on the target
"note_patient_id": { "type": "string", "x-fk": { "record_type": "patients", "key": "id" } } // matches the target's system id
keyis the field on the target that the value must match —external_idby default, the target's systemid, or any top-level field (e.g. acode).- Use
key: "id"when the target is a native record with no externalid. The system id is assigned when the record is created, so create the target first, read itsidback, then reference it — this can't be done in one batch pass or a seed fixture (those resolve byexternalid), so useexternal_id/data keys there. - Checked on every write (create, full upsert, partial update, batch). Empty/absent values are skipped — mark the field
requiredif it must be present. - The restriction is on the referencing type. The target may itself use external sources; Rekor skips the local existence probe for such a target because its rows live upstream.
- In a batch, write the target before the record that references it (the batch is atomic — a bad reference rejects the whole batch). This ordering only helps
external_id/data keys — anidkey needs the target created in a prior write (see above). - Use foreign keys for reference data (patients, plans, products), not high-volume event logs — a referenced record_type is kept readily available so the check stays fast.
Attachments
Store large or binary content (PDFs, images, files) as attachments on a record and reference them from the record data — record/relationship JSON is for structured data and is capped at ~1 MiB, so inlining base64 blobs is rejected. Filenames may contain paths for folder structure (e.g. docs/guide.md). For versioning, metadata schemas, compare-and-set, or S3 mounting, prefer Files (below) — the richer evolution of attachments.
rekor attachments upload <record_type> <id> --base <ws> --filename <name> [--content-type <mime>] [--file <path>]
rekor attachments url <record_type> <id> --base <ws> --filename <name>
rekor attachments list <record_type> <id> --base <ws> [--prefix <path>]
rekor attachments delete <record_type> <id> <attachment-id> --base <ws>
upload with --file uploads the local file directly; without --file, upload returns a presigned URL you PUT the bytes to. url returns a download URL for fetching an existing attachment. --prefix filters list to a path prefix (e.g. docs/).
Files
First-class, versioned, path-addressed files — the richer evolution of attachments. Store PDFs, images, generated reports, or any large/binary content as a File inside a file type (a "bucket" that configures storage policy — max size, allowed content-types — and an optional metadata schema). A file is addressed by a mutable path; renaming moves only the address, never the bytes. Files support compare-and-set (upload only if the current content hash matches, via --if-match), structured metadata, and linking to records.
Configure file types (in a preview base, then promote):
rekor file-types upsert <id> --base <ws> --name <name> [--max-size <bytes>] [--allowed-content-types <json|@file>] [--metadata-schema <json|@file>]
rekor file-types list --base <ws>
rekor file-types get <id> --base <ws>
rekor file-types delete <id> --base <ws>
Work with files:
rekor files put <file_type> <path> --base <ws> --file <local> [--content-type <mime>] [--metadata <json|@file>] [--label <tag>] [--if-match <sha256>]
rekor files get <file_type> <path> --base <ws> [--output <local>] [--meta] [--version <n>]
rekor files list <file_type> --base <ws> [--prefix <path>] [--depth <n>]
rekor files mv <file_type> <from> <to> --base <ws>
rekor files rm <file_type> <path> --base <ws>
rekor files history <file_type> <path> --base <ws> [--limit <n>] [--offset <n>]
rekor files diff <file_type> <path> --base <ws> --from <n> --to <n>
rekor files mount <file_type> --base <ws> [--mode <read_only|read_write>] [--path <prefix>] [--expires-in <seconds>]
File content versions. Every content change to a file is retained as an immutable version. Tag a version with --label (e.g. --label "proposal 1") on put, and read any earlier one with rekor files get --version <n> (reads return the latest by default). Renaming or re-tagging a file — metadata only, no new bytes — does not create a new version. List a file's versions with rekor files history <filetype> <path>, and compare two with rekor files diff <filetype> <path> --from <n> --to <n> — you get a metadata delta plus a line-by-line content diff for text files.
Mounting a file type as a filesystem. rekor files mount <filetype> mints an S3-compatible credential so any harness or tool that speaks S3 (s3fs, cloud storage mount SDKs) can mount the bucket as a local folder and read files with ordinary filesystem calls — no per-tool integration. It prints an endpoint, bucket name, and access keys (the secret is shown once); point your S3 client at them with path-style addressing. Confine a credential to a sub-tree with --path <prefix> (repeatable), and set its access with --mode readonly (default) or --mode readwrite; minting a write credential requires write access to files. A readwrite mount can also write files back — writes are conflict-safe and versioned: each change is checked against the version you started from (a create requires the path be new; an update requires the file be unchanged since you read it), so a stale overwrite is refused rather than silently clobbering a concurrent change. Every write is a new version with full history. (read_only mounts read only; write elsewhere via rekor files put or the API.)
Linking files to records. A relationship can point at a file (its endpoint kind is file). List a record's linked files with GET /v1/{base}/records/{recordtype}/{id}/related-files. To create a file and link it to a record in one step, POST /v1/{base}/records/{recordtype}/{id}/attach (body = the file bytes; filetype, reltype, path as query params). If the link's relationship type is declared cascade, deleting the link (or the owning relationship) removes the file; a non-cascade link is a plain reference, so a file shared across records survives. Production agents manage files with the managefile MCP tool (create text content / get metadata / list / move / delete / history / diff) and file types with managefile_type.
SQL Query
Execute read-only SQL queries directly against base data. Supports filtering, aggregation, JOINs, CTEs, and array operations.
rekor sql "<query>" --base <ws> [--param key=value ...] [--file query.sql]
Tables: records, relationships, recordtypes, relationshiptypes, bases, operationslog, organization, auditlog
The organization table exposes org-level metadata. Its plan and status columns are not maintained and carry no billing meaning — your plan belongs to the platform that manages your account, not here. Do not read them as an entitlement; depending on when the org was created they hold either an empty string or a stale default.
Three tables have no baseid column, so bind {baseid:String} to the column that actually identifies the base:
| Table | Base-scoping predicate |
|---|---|
bases |
id = {base_id:String} |
operations_log |
data.baseid.:String = {baseid:String} |
organization |
none — it is org-level, with no per-base row |
organization therefore cannot be answered by this base-scoped endpoint; read it from the org-wide SQL surface (POST /v1/orgs/{orgid}/sql), which scopes by {orgid:String} alone. Keep orgid = {orgid:String} in all three cases.
The auditlog table is the change history behind rekor records history — every version of every entity, with the actor. Any query referencing it (including via JOIN) is admin-gated the same way: platform admins, or a token granted read:audit. It and operationslog are append-only, so neither takes a deleted = false predicate.
Important: Always include BOTH orgid = {orgid:String} AND baseid = {baseid:String} (bound to the base-identifying column per the table above), plus deleted = false — except on the append-only auditlog and operationslog, which have no deleted column and reject the predicate. Both scoping predicates are mandatory — a base id is unique per-org, not globally, so org_id is required to isolate your data. Both placeholders are bound server-side from your authenticated org and base. The records table also carries archived and cancelled boolean columns — add archived = false to query only active records. Queries always see the latest version of each row — the server handles deduplication.
Eval clones: a preview cloned from another preview runs on the local analytics tier, where SQL over records, relationships and audit_log is refused; every other table still answers — see Bases → Analytics tier.
Accessing JSON fields: Use data.field.:Type subcolumn syntax for the native JSON type. Use CAST(data.field, 'Type') when type-safe conversion is needed (e.g., integers stored as Int64 vs Float64).
Examples:
# Simple query
rekor sql "SELECT data.invoice_number.:String as num, data.status.:String as status FROM records WHERE org_id = {org_id:String} AND base_id = {base_id:String} AND record_type = 'invoices' AND deleted = false" --base my-ws
# Aggregation
rekor sql "SELECT data.status.:String as status, count() as cnt FROM records WHERE org_id = {org_id:String} AND base_id = {base_id:String} AND record_type = 'invoices' AND deleted = false GROUP BY status" --base my-ws
For the full example gallery — array aggregation, ARRAY JOIN to explode embedded arrays, CTEs joining records with relationships, --param binding, and fuzzy/jaroWinklerSimilarity ranked text match — see references/querying.md.
Relationship Types
A relationship type defines a reltype and is required before any relationship of that type can be created (a config operation — preview then promote). --schema (optional) validates relationship metadata; --source-recordtypes/--target-recordtypes (optional) restrict which recordtypes it may connect.
rekor relationship-types upsert <rel_type> --base <ws> [--description <text>] [--schema <json>] [--source-record_types <json>] [--target-record_types <json>]
rekor relationship-types list --base <ws>
rekor relationship-types get <rel_type> --base <ws>
rekor relationship-types delete <rel_type> --base <ws>
rekor relationship-types history <rel_type> --base <ws> [--limit <n>] [--offset <n>] [--diff]
Deleting a relationship type also removes all relationships of that type.
Relationships
rekor relationships upsert --base <ws> --type <type> [--source <col/id> | --source-external <col/external_id> [--source-external-source <name>]] [--target <col/id> | --target-external <col/external_id> [--target-external-source <name>]] [--id <id>] [--external-id <key> [--external-source <name>]] [--data <json>]
rekor relationships get <id> --base <ws> [--rel-type <type> [--external-source <name>]]
rekor relationships delete <id> --base <ws> [--rel-type <type> [--external-source <name>]]
rekor relationships history <id> --base <ws> [--limit <n>] [--offset <n>] [--diff]
rekor query-relationships <record_type> <id> --base <ws> [--type <type>] [--direction outgoing|incoming|both] [--limit <n>] [--offset <n>]
Each endpoint takes exactly one of --source/--source-external (same for target). External addressing resolves active records only and requires the record to exist; an internal id is stored as given.
A relationship can also carry its own external key: --external-id on upsert makes creation idempotent (a retried upsert with the same key updates the first row instead of duplicating it), and get/delete accept that key in place of the id when --rel-type scopes it. In the API these are the top-level externalid/externalsource fields (also on batch upsertrelationship ops and generated link tools), and GET/DELETE /relationships/<externalid>?rel_type=<type> for addressing.
The --type must be a declared relationship type. --data is validated against that type's schema.
Integration Modeling (canonical-first)
The integration edges below — inbound webhooks, triggers, and external sources — connect a record_type to an external system. This is how to decide which edge to use and how to shape the entity behind it. Companion to the Modeling Principles for Agent Consumption section (near the end), which shapes the agent-facing tool surface; this shapes how the data is backed.
The one rule: model the entity canonically first — agnostic to any integration — then decide, per operation, how it's backed. The record_type IS the entity: a clean schema with business-named properties, sensible enums and defaults — never the upstream payload's shape. Integration lives at declarative edges, never smeared into the schema or tool surface. A consumer (an agent, a query) should not be able to tell whether a field is rekor-native or mirrored from a legacy system.
- Model the canonical first. Derive the schema and tool surface from the domain and the agents' real journeys, not from any backend's payload. Put each business rule at the most reliable layer it fits — schema (enums,
required, defaults) > trigger (reactive invariant) > tool constraint (writable_fields/precondition) > prompt — and never encode an enforceable data invariant as a prompt rule. Right-grain it: model only what the journeys touch and extend additively (speculative schema is harder to remove than to add). - Swap-test. If re-backing an entity (legacy → native → a different system) would force a change to its schema or to the semantic tool surface — tool names,
datafields, filter params — the model leaked the integration; fix the model. Only an integration-agnostic model is swappable: the same canonical contract can be served natively in one base and proxied to a legacy API in another by swapping only the source'sfieldmapping. What re-backing does change is the envelope addressing params (externalid/externalsource), which the source takes over — see External addressing is published by source topology in the MCP Factory reference. That is the machinery moving server-side, not a leak; but a prompt or harness that passesexternalidon create is coupled to the backing choice, so keep it out of your agent's instructions. Backfill catalog gaps the legacy system lacks with native record_types in the same base, exposed through the same toolset. - Offline evals are a free payoff — the swap-test from the other side. The preview-only
--integrations disabledeval toggle (see Bases above) works precisely because the model is integration-agnostic: where the swap-test re-backs an entity without touching its surface, the eval toggle un-backs it without touching its surface — disable the source and the same canonical schema, tools, and field mappings keep serving, now from seeded local data. So model canonical-first and you get deterministic, prod-safe agent evals for free: it's one principle, not a separate feature. - Native vs proxy is a per-recordtype choice; a source's operation blocks declare which operations the upstream exposes. A recordtype with no sources is native (all CRUD local); a recordtype with a source is proxy-backed — every write goes upstream and reads resolve against the upstream (it stores nothing locally). The source's
list/get/create/update/deleteblocks pick which operations the upstream supports (read-onlyget/list, or full read-write); an operation the source omits is unsupported, not silently native. You don't split native and proxy across the operations of one recordtype — you mix them across record_types: one toolset freely composes native-backed and proxy-backed tools (a proxiedinvoicesbeside nativeplans/activitiesyou backfilled). - Don't route native-vs-proxy per tool or per field. Native-vs-proxy is a per-recordtype choice, never per-tool;
writablefields restricts which fields a tool writes, never where. (Two proxy tools on the same op MAY select different named write bindings of the source — see External Sources — but that only picks which upstream endpoint a write hits; it stays all-proxy and never makes one tool native and another proxy.) A "native read + proxy write on one recordtype" mix is a read-consistency hole (the native read won't reflect the proxy write until something syncs it) — and once you add that sync, native-write + anexternalwrite trigger is strictly better. The two coherent shapes are all-proxy or all-native + sync; the per-tool mix is the incoherent middle. - Prefer the native mirror; reach for proxy only when forced. A native record_type kept in sync gives local query/join/filter, speed, transactional writes, audit, and availability decoupled from the upstream. Choose proxy-on-read only when staleness is unacceptable, a write must be synchronously confirmed by the upstream system of record, or the dataset is too large/cold to mirror.
- Hybrid entities (mirrored fields + a rekor-owned overlay) live in ONE recordtype. A field the upstream lacks (a workflow
status, a tag, a score) belongs on the same record as the mirrored fields — integrated query/filter demands it (you can'tlist where status=Xifstatuslives in another recordtype). Splitting an entity to separate ownership trades a real query capability for bookkeeping; don't. Exception: an overlay that is itself a richer entity with its own lifecycle/history → a related record_type + relationship. A scalar attribute is never a reason to split. fieldmappingis one bidirectional contract, reused at every edge. The same source contract powers proxy reads, write-through, inbound-webhook hydration (sourcebinding), andexternal_writeout. Define the translation once; the edges reuse it.- Reference webhooks: hydrate via the source's
get, never follow the payload's callback. Most mature webhooks are reference-style ("record X changed, go fetch it") — small payload, no stale snapshot, self-correcting ordering. Resolve them by extracting the id and fetching through the bound source'sget. A payload-supplied callback URL is an SSRF vector and is ignored.
Pattern catalog — pick the edge by what the upstream offers and what you need:
| Pattern | Source / edge shape | Reads | Writes | Use when |
|---|---|---|---|---|
| Native | sources: [] |
local | local | rekor owns the data |
| Proxy-read | source list/get |
live upstream | — | freshness-critical or too-big-to-mirror reference data |
| Write-through | source create/update (+ get/list to read) |
live upstream | → upstream (id returned) | upstream is system of record; you want synchronous confirmation, no local mirror |
| Push-synced mirror | hydrating inbound webhook + bound source get |
local | local | upstream pushes change notifications; you want local query of a fresh mirror |
| Bidirectional mirror | hydration in (merge) + externalwrite trigger out (watchedfields) |
local | local (→ upstream via trigger) | a hybrid entity synced both ways, all-native tools |
The bidirectional mirror is the idiomatic home for a hybrid entity: one native recordtype, all-native tools, sync as declarative edges. Use hydration merge so a re-sync refreshes only the mapping-owned fields and preserves your overlay, and externalwrite watchedfields: "mapped" so an overlay-only change doesn't push a no-op upstream. The hydration write target must be native (no source) — the out-edge is the externalwrite trigger, not a proxy write on the same recordtype — and skipinboundwebhookwrites keeps a re-sync from re-firing the trigger (no loop).
Identity keys. Catalog / reference entities (plans, activities, locations) → key by a stable slug that doubles as externalid, so cross-recordtype x-fk references resolve. People / relations (members, bookings, enrollments) → key by rekor's internal uuid.
Inbound Webhooks (data in)
External systems push data into Rekor via inbound webhooks. Each one provides a unique ingest URL.
rekor inbound-webhooks create --base <ws> --name <name> --secret <hmac-secret> [--id <id>] [--record_type-scope <comma-separated>] [--field-mapping <json>] [--source-binding <json>] [--ingest-auth <json>]
rekor inbound-webhooks list --base <ws>
rekor inbound-webhooks get <id> --base <ws>
rekor inbound-webhooks delete <id> --base <ws>
--secret is the shared secret (required). By default the sender signs ingest requests and Rekor verifies an HMAC: either Signing v1 — X-Data-Signature: v1,<hex> over id+timestamp+method+path+body, with an X-Data-Timestamp Rekor checks for freshness (the same scheme triggers and proxied calls use, so one signer works both directions) — or a legacy body-only HMAC for older senders. A v1 delivery is idempotent: a duplicate (a retry or replay carrying the same X-Data-Id) replays the first response instead of writing again. For senders that authenticate with a static per-account header instead of signing each request, --ingest-auth '{"type":"staticheader","header":"X-Account-Key"}' verifies that header's value against --secret (constant-time) — Rekor compares the configured header rather than a signature. --recordtype-scope restricts which record_types the inbound webhook may write to (omit for all). Inbound webhooks can only be created/deleted in preview bases. Promote to production when ready.
Translate the received payload before write. By default an inbound webhook stores the payload as-is. To store canonical records straight from an external system's raw shape, attach a mapping — the same field_mapping contract external sources use (renames, value maps, date reformatting, computed/compose), applied in the inbound direction:
--source-binding '{"recordtype":"<id>","source":"<name>"}'reuses an existing source'sfieldmapping, so the same translation that proxies that record_type's reads/writes also canonicalizes inbound deliveries — one contract, both directions.--field-mapping '{"toexternal":{"status":"state"}}'is an inline mapping for a purely-native recordtype with no source.toexternalrenames auto-invert on read (upstreamstate→ Rekorstatus); or givetorecord/computedexplicitly.
The two are mutually exclusive. The mapping is validated when the webhook is created and again at promotion (a sourcebinding must still resolve to a source that has a fieldmapping). This makes an executor-free native-mirror sync complete: pair an inbound webhook's torecord with an externalwrite trigger's toexternal to keep a native recordtype in sync with a plain-HTTP upstream in both directions, reusing a single source contract.
Hydrating webhooks (notification + fetch). Many systems send reference-style webhooks — "record X changed, here is its id" — instead of the full record (Stripe recommends re-fetching by id; Shopify, Salesforce, HubSpot, legacy CRMs do the same). Point such a webhook at a record_type's read source and Rekor will fetch the full record itself on each delivery, then store the canonical record:
rekor inbound-webhooks create --base <ws> --name orders-ref --secret <token> \
--record_type-scope orders \
--source-binding '{"record_type":"orders","source":"shop_api"}' \
--ingest-auth '{"type":"static_header","header":"X-Account-Key"}' \
--hydration '{"id_path":"record_id","event_path":"event","event_map":{"ResourceDeleted":"delete"}}'
On a delivery, Rekor reads the record id from the thin body (idpath), calls the bound source's read endpoint to pull the full record, canonicalizes it through the source's fieldmapping, and upserts it by externalid — the inbound twin of a proxied read. The event field (eventpath + eventmap) routes to upsert or delete (a delete skips the fetch and removes the mirrored record); everything else defaults to upsert. The fetch + write happen asynchronously with automatic retry/backoff, so the sender gets an immediate accept; check progress with rekor inbound-webhooks deliveries --base <ws>. Requires --source-binding (it provides the read endpoint) and a single native --recordtype-scope. Security: Rekor only ever fetches the source-configured URL with the extracted id — a callback URL inside the payload is ignored, never followed. Reference-style senders typically pair with --ingest-auth (a static header), since they don't sign each delivery.
Keep your own fields on re-sync — merge. By default each re-sync replaces the whole record, so any field you added locally (a workflow status, tag, score, assignment, internal note) is overwritten. Add "merge":true to --hydration and a re-sync refreshes only the fields the source's mapping owns and preserves everything else you put on the record — so one recordtype can hold both the upstream mirror and your local enrichment, and you can still list/filter the entity by your own field (e.g. status=qualified). The first sync still creates the record from the mapped fields; a field the upstream later clears is reflected; the merged record is still schema-validated. Example: --hydration '{"idpath":"record_id","merge":true}'.
Triggers (data out)
Triggers fire automatically when records or files change. A trigger's action is one of:
- webhook — an HMAC-signed HTTP POST to an external system (
--url/--secret). - internal_write — Rekor updates a referenced record for you, with no external service. On a matching write it locates a target record and applies a guarded patch (an optional compare-and-set
preconditionso the update only lands when the target is still in the expected state). Ideal for "when A changes, update related B" — e.g. booking an appointment flips its slot to busy only if it was free. Pass it via--action(see below); record.created/updated only. Two modes:asyncapplies the patch just after the triggering write (eventual; a precondition miss is recorded but the triggering write still stands), andtransactionalapplies it atomically with the triggering write — if thepreconditionmisses, the whole write is rejected and rolled back (nothing is created). Usetransactionalwhen the two writes must succeed or fail together (e.g. don't create the appointment unless the slot was free). - externalwrite — Rekor writes the changed record out to an external system by reusing an external source's write path — its field translation, endpoint, body shape, and auth — with no executor. Use it to keep a native recordtype mirrored to a legacy API: store records natively in Rekor and have each write pushed upstream in the upstream's own shape. The action names the source:
{type:"externalwrite","recordtype":"<source-owner>","source":"<name>","op":"create"}.recordtypeis the recordtype that defines the source (a native mirror points at a sibling source-backed recordtype; defaults to the triggering recordtype if omitted);opdefaults from the event (created→create, updated→update, deleted→delete) and is overridable. On a create, Rekor reads the upstream-assigned id from the response and links it back onto the triggering record as itsexternalid(so the native record points at its upstream counterpart, and retries don't duplicate) — opt out withlinkexternalid:false. Updates/deletes are dispatched only for records already linked to that source. By default every matching update is pushed upstream; addwatchedfieldsto scope update dispatch to writes that actually changed an upstream-mapped field —"mapped"watches exactly the fields the source maps upstream (so a write touching only a Rekor-owned field — one the source doesn't map out — skips the redundant no-op upstream push), or pass an explicit array of data-field names. Creates and deletes always dispatch. Delivery is async, signed, retried, and dead-lettered like a webhook (inspect withrekor triggers deliveries); record.created/updated/deleted only.
rekor triggers create --base <ws> --name <name> --events <comma-separated> --url <url> --secret <hmac-secret> [--id <id>] [--record_type-scope <comma-separated>] [--filter <json>]
# internal_write action (instead of --url/--secret):
rekor triggers create --base <ws> --name <name> --events record.created --record_type-scope appointments \
--action '{"type":"internal_write","target":"slots","match":{"source_field":"data.slot_id"},"patch":{"status":"busy"},"precondition":{"field":"data.status","op":"eq","value":"free"},"mode":"async"}'
# external_write action — mirror a native record_type's writes out through a source's write path:
rekor triggers create --base <ws> --name <name> --events record.created,record.updated --record_type-scope appointments \
--action '{"type":"external_write","record_type":"upstream_appointments","source":"legacy","op":"create"}'
rekor triggers list --base <ws>
rekor triggers get <id> --base <ws>
rekor triggers delete <id> --base <ws>
rekor triggers deliveries --base <ws> [--status <pending|delivered|failed|dead>] [--trigger-id <id>]
--events is a comma-separated list (not a JSON array). Valid events: record.created, record.updated, record.deleted, record.cancelled, relationship.created, relationship.updated, relationship.deleted, file.created, file.updated, file.deleted — e.g. --events record.created,record.updated. File events fire when a file is written (created / any content-or-metadata change / deleted); a file trigger's --recordtype-scope names file types, and a --filter matches the file's metadata (e.g. data.status). File events are webhook-only (the internalwrite/externalwrite actions are record-only). (Bare names like create/update will silently never match.) For a webhook, --secret is the HMAC signing secret receivers verify with. --recordtype-scope limits firing to specific recordtypes (omit for all). An internalwrite target record stays available as long as a trigger references it. For externalwrite, the referenced source is validated at create time (the named recordtype, source, and write endpoint for each op must exist) — and referencing a source from a trigger never turns the triggering record_type into a proxy; it stays native.
Add --filter '<json>' to fire only on records matching a condition — the same filter DSL queries use, evaluated against the record (or relationship) being written, e.g. --filter '{"field":"data.status","op":"eq","value":"paid"}' fires only when status is paid. Combine conditions with and/or groups. A malformed filter is rejected at create time.
Triggers are HMAC-signed (X-Data-Signature: v1,<hex> over id+timestamp+method+path+body, with an X-Data-Timestamp the receiver checks for freshness — the same scheme proxied requests use) and carry X-Data-Id/X-Data-Delivery-Id for receiver dedupe. Delivery is reliable — failed attempts are retried with backoff and dead-lettered after repeated failure; inspect status with rekor triggers deliveries. By default, writes from inbound webhooks don't re-fire triggers (skipinboundwebhook_writes: true). Triggers can only be created/deleted in preview bases.
Executors (acting on the outside world)
Rekor records, signs, and dispatches — but the actual outside-world action (calling a third-party API, presenting a client certificate, running logic Rekor can't) happens in an executor: a small, stateless HTTP service you deploy and point a trigger or external source at. Rekor signs every dispatched request; the executor verifies it, does the work, and writes the result back.
Do you even need one? A plain REST/JSON API → just an external source (no executor) — and the same source's write path can be reused outbound by an externalwrite trigger, so mirroring a native recordtype to a plain-HTTP upstream needs no executor either. Build an executor only when a trigger runs custom logic, or a source call can't be a direct HTTP request (mTLS, SOAP, binary creds, heavy processing). Inbound webhooks go the other way — your executor calls an inbound webhook to write results back in.
Always receive these requests with the @wayai/executor-sdk package — never hand-roll signature verification (a mistake lets anyone forge a request to your executor). The SDK verifies the signature + timestamp, dedupes retries on the idempotency key, and normalizes errors — you write one handler:
import { createExecutor, toFetchHandler } from '@wayai/executor-sdk'
const handler = toFetchHandler(createExecutor({
secret: process.env.REKOR_SIGNING_SECRET!,
handler: async (ctx) => { /* ctx.body is verified; do the work, return a result or nothing */ },
}))
// Hono: app.post('/rekor', (c) => handler(c.req.raw)) • Cloudflare: export default { fetch: handler }
Where to run it: default to a serverless platform (Cloudflare Workers, Vercel, Deno Deploy, AWS Lambda — pick whichever you already use; the contract is platform-agnostic) — most executors are just authenticated API calls, and a serverless function is cheap, fast, and scales to zero. Step up to a long-running container or server (Fly.io, Railway, Render, a VM) only when a serverless function can't do the job: mutual-TLS client certificates, long-running work, raw TCP/SOAP, or native dependencies.
For the full contract, framework variants, the certificate-via-vault pattern, and local dev, read references/executors.md (bundled alongside this skill).
External Sources (back a record_type with an external API)
A recordtype can declare one or more external sources. When a record operation carries an externalsource that matches a configured source name, Rekor proxies that read/write to the external API (or an executor) instead of its own store — so an agent reads and writes stripe-backed invoices through the same rekor records/MCP calls, with no separate integration code.
Configure sources as part of the record_type schema (preview only, promoted like any schema change):
rekor record-types upsert invoices --base my-ws--preview --name "Invoices" \
--schema @schema.json --sources @sources.json
Each source defines the menu below (full grammar, every option's rules, and a worked legacy-upstream example in references/external-sources.md):
name— must equal the record'sexternal_source.auth— header + value template; secret inline orvault:<name>. An inline secret is environment-local — promotion never copies it, so set it on production directly (a newly promoted source stays unconfigured, requests fail with a clear error, until you do). Avault:<name>reference travels by reference.fieldmapping— optional (omit for identity passthrough).toexternal/torecordmap between your schema and the upstream's field names — a simple rename or a rich rule (transform,values[always Rekor-keyed],default,dateformat,array_mode,target). Advanced shapes: split one field to several params (targets), destructure a composite value (separator+parts), compose a Rekor field from several upstream fields (computedtemplates), and per-operation overrides.get/list/create/update/delete— per-operation endpoint templates, each with its ownurl(tokens{{externalid}},{{query.}},{{data.}},{{current.}}/{{prior.}},{{auth.*}}) andmethod— so RPC-style verb paths and all-POST upstreams work directly.responsepath/totalpath/successpath/message_pathunwrap envelopes.- Named write bindings (
create/update/delete) — declare a write op as a map of named endpoint variants when one canonical entity's writes fan out to several backend endpoints; the caller picks one by name (via a tool'sbindings), keeping ONE record_type. idpath— where theexternalidlives in each raw upstream record (dot-path, composite template, or create-only/request-templated variants) for upstreams not keyed onid.forward_filters(onlist) — forward the agent'seqfilter conditions upstream (allowlistedfields,query/bodytarget); also exposed to read mappings as{{filter.<field>}}.localfilters(onlist) — for an upstream with no filter params: declare the collection bounded, fetch it whole, and Rekor applies the full filter + sort in-memory (all operators butsearch; native pagination). Mutually exclusive withforwardfilters; optionalmax_rows(default 500).staticbody/bodytemplate/request_encoding— constant, templated, and form-encoded request-body shaping (e.g. put the id in the body for legacy verbs).injections/executor_secrets— extra per-request vault secrets in a header/body, and named credentials an executor pulls at dispatch (e.g. an mTLS cert).cachettl+staleiferror/signing/timeoutms/breaker— read-through caching, HMAC signing (for executors), per-request timeout, and the per-source circuit breaker.
URLs must be absolute https (or http with allowinsecurehttp: true) and tokens may appear only in the path/query (never the host) — an SSRF guard rejects otherwise.
Batch (atomic)
Execute up to 1,000 operations atomically — all succeed or all fail:
rekor batch --base <ws> --operations '[
{"type":"upsert_record","record_type":"invoices","external_id":"inv-1","data":{"customer":"A","amount":100}},
{"type":"upsert_record","record_type":"customers","external_id":"cust-1","data":{"name":"A"}},
{"type":"upsert_relationship","rel_type":"belongs_to","source_record_type":"invoices","source_external_id":"inv-1","target_record_type":"customers","target_external_id":"cust-1"}
]'
Operation types: upsertrecord, deleterecord, upsertrelationship, deleterelationship, upsertrecordtype, deleterecordtype
A relationship op addresses each endpoint by internal id (sourceid/targetid) or by external key (sourceexternalid + optional sourceexternalsource) — exactly one per endpoint. Because ops run in order inside one transaction, the example above creates two records and links them in a single atomic call: the link resolves the external_ids the earlier ops just wrote, and a resolution miss rolls the whole batch back.
Reading results: the default (table) output prints a one-line summary per operation (index, type, resulting id). Add --output json for the full per-operation payload — the complete record/relationship of every write. Because the batch is atomic, a single failing operation rolls the whole batch back and the command exits non-zero with Operation <N> failed: <reason> naming the offending index — no partial writes land.
Bulk Import
For a historical backfill, import is the purpose-built path — it writes like ordinary upserts but skips trigger fan-out (no webhooks, no internal/external write cascades fire for backfilled rows), which is why it needs the dedicated write:imports permission on top of write:records.
Use the CLI. It streams the file (so size is not bounded by memory), chunks it, and handles the resume and month-boundary cases below for you:
rekor import run patients --file patients.ndjson --base prod # one JSON object per line
rekor import list [--limit <n>] [--offset <n>] --base prod # sessions, newest first
rekor import rollback <import_id> --base prod # undo (prompts only in a terminal)
run prints the request_key it generated. Keep it: if the run dies partway, rekor import run … --request-key <key> --start-chunk <n> resumes from that chunk instead of re-sending the file. Pass the same --chunk-size you used originally — chunk numbers are derived from it, so resuming under a different size would point index N at different rows; the CLI requires the flag explicitly whenever you use --start-chunk. If a run stops partway it tells you the chunk index to resume at — re-run the same command with the --start-chunk/--chunk-size/--request-key it prints and every other flag unchanged. Do not retype the command from scratch: a too-high --start-chunk skips rows silently, and dropping --base resumes into a different base entirely. (--org is inert — the key decides the organization.) --chunk-size is ≤1,000; --no-finalize leaves the session open for a later resume.
If you are driving it yourself rather than using the CLI — a language runtime, a scheduled job — the REST surface is:
POST /v1/{base_id}/import/begin {"request_key":"<your-run-id>","record_type":"<record_type>"}
POST /v1/{base_id}/import/chunk {"import_id":"<from begin>","chunk_index":0,"rows":[{"external_id":"…","data":{…}}, …]}
POST /v1/{base_id}/import/{import_id}/finalize
POST /v1/{base_id}/import/{import_id}/rollback — undo the import (deletes only rows it CREATED)
GET /v1/{base_id}/import/{import_id}/rollback-plan?counts=true — what an undo would reach, before doing it
GET /v1/{base_id}/import/sessions — list sessions (newest first) to find or recover a stuck run
Contract highlights:
beginis idempotent byrequestkey(pick one per logical run): a lost response is safely re-sent and returns the same session. Reusing a key with a different record type while the session is open is a 409 — one key, one record type per run. Once a session is finalized its key is free again (tomorrow's run of the same job can reuse it and gets a fresh session). Abeginthat returns 503 could not read your remaining allowance and created no session — retry it with the samerequestkey.- Chunks are ≤1,000 rows, indexed by
chunkindex, and each is receipted server-side: re-sending the same chunk (same index, same content) replays the recorded result without re-applying — so a timeout or dropped connection is safe to retry (like a seed-lease exact retry, and unlike every other data write). Re-sending an index with different content is a 409 (IMPORTCHUNKHASHMISMATCH); resuming from edited input needs a new session. - Rows upsert by
externalid; a row whose content already matches is skipped without a write, so re-running a full refresh only rewrites actual changes. One exception: a matching row not written to in ~45 days is rewritten anyway (version bump, an update in its history) to keep periodically re-imported records counted as active rather than drifting into archival — so refreshes sparser than that see every row rewritten, androwsskippedis not a reliable idempotency assertion across long gaps. - Every validation and archival/workflow gate applies exactly as on normal writes — import into an
immutablerecord type is create-only. Native record types only (a source-backed type can't be bulk-written). - A session idle for 24h has its open slot reclaimed (this happens when the next
beginorfinalizeruns on the base, so an untouched idle session may linger until then); at most a handful may be open per base —finalizewhen done. A session also ends at the UTC month boundary, because its included-row allowance belongs to the month it started in: the first chunk sent in a new month returns 409IMPORTSESSIONCLOSEDwithdetails.status: "expired". Recover by callingbeginagain (the samerequest_keyreturns a fresh session) and re-sending that chunk — rows already written are skipped, so crossing a boundary costs one extrabegin, not a re-import. Adetails.statusofclosedmeans the run was deliberately finalized instead; don't restart that one automatically.
Undo: rekor import rollback <importid> (or the REST route) soft-deletes only the rows the import created — a row it merely overwrote stays, keeping the imported values (there is no version-revert). Links pointing at deleted rows go too, so nothing is left dangling. It works in pages: each call returns swept and complete, so keep calling until complete is true — the CLI loops for you. In a terminal it first shows the live matched count and asks to confirm (--yes skips that). Anywhere else — a script, CI, or an agent, none of which is a TTY — there is no prompt and the sweep starts immediately, following the same convention as the other destructive commands. Check the count yourself first with GET /v1/{baseid}/import/{importid}/rollback-plan?counts=true if you want one. Retrying is always safe — pages already swept stay swept, so a call that fails with a retryable 503 (a busy base) never resurrects what you already undid; just call again. Like the import itself, it fires no triggers — neither the deletes nor the removed links notify anything, so a trigger that syncs deletions to another system will not see them and that system keeps the rows. It needs write:imports, write:records for the record type, and write:relationships (the sweep may always remove links). If a rolled-back record had files attached, the attachment link goes but the file itself stays — undo removes only what the import created, and the file was added afterwards; delete it explicitly if you don't want it. Two further limits worth knowing: rows already moved to archival storage are out of reach, so the practical undo window is roughly the archival cadence, and for an immutable record type re-import cannot overwrite them either — importing into those is correct-first-time territory. Re-importing with corrected data is usually the better fix: rows upsert by externalid, so a corrected re-run overwrites in place.
Your plan includes bulk-import rows equal to its record cap each month; rows past that cost 1 op per 20 rows, rounded up per request to a minimum of 0.1 op (so send rows in reasonable batches — smaller chunks never cost less). Only rows actually written count — skipped (unchanged) rows and replayed chunks are free, so a re-run costs only what changed, with the ~45-day archival rewrite above as the one exception: a refresh sparser than that rewrites and bills every row. begin/finalize are free, each rollback call costs 1 op (so undoing a large import costs one op per page), and the rollback-plan pre-check costs 1 op with ?counts=true (it walks the import's rows to count them) or 0.1 without. Interactive (non-token) imports are not metered, and the plan record cap still applies to imported rows.
Seed Fixtures (hermetic eval data)
A fixture is a named, reversible set of records (and relationships) you apply to a preview base to give agent evals a clean, re-runnable starting state. Author it as config-as-code and drive it by name — so an eval harness (even a headless/cloud one, using a scoped token) can reset the data around each run without a dirty base carrying over.
Author the fixture as a file under the base's config folder, then push it with the base:
# rekor-ws/bases/<preview-base>/seeds/demo.yaml
id: demo
description: Overdue-bills demo baseline
records: # author FK targets BEFORE the records that reference them
- record_type: customers
external_id: cust-1
data: { name: Ada }
- record_type: bills
external_id: bill-1
data: { customer_id: cust-1, status: overdue, amount: 120 }
relationships: # endpoints reference records by external_id
- rel_type: owes
source: { record_type: customers, external_id: cust-1 }
target: { record_type: bills, external_id: bill-1 }
exclusive_record_types: [customers, bills] # optional: reset also removes non-declared records of these types
rekor push # store the fixture on the preview (pushes with the rest of the config)
rekor seed apply demo --base <preview> # write the fixture's records/relationships
rekor seed reset demo --base <preview> # restore the declared baseline (fixes records a run mutated)
rekor seed clear demo --base <preview> # delete every row the fixture owns (teardown)
rekor seed list --base <preview> # show the fixtures defined on the base
For concurrent or outcome-unknown eval runs, use an actor-bound seed lease instead of calling reset and clear separately. Generate a fresh UUIDv7 once and persist the immutable tuple until release; ownerref is only a safe run label and never grants authority. It must be 1–200 characters, start with a letter or digit, and otherwise use only letters, digits, or .:@/+~=- (no whitespace).
rekor seed lease acquire demo --base <preview> \
--lease-id <uuidv7> --owner-ref <safe-run-id>
rekor seed lease status --base <preview> --lease-id <uuidv7>
rekor seed lease release demo --base <preview> \
--lease-id <uuidv7> --owner-ref <safe-run-id>
Acquire atomically resets the fixture and locks the entire preview's fixture-data operations; release atomically clears the fixture-owned data and unlocks it. Only the same verified user or Rekor token may retry, inspect, or release the tuple. Exact retries are no-ops, released IDs are never reusable, and another authorized actor learns only that the base is locked. If an acquire times out with an unknown outcome, retry the same tuple. The permanent ledger is capped at 1,000 IDs per preview (warnings begin at 800 and 950); replace the disposable preview when exhausted.
REST equivalents are POST /v1/{baseid}/seed-leases/acquire, POST /v1/{baseid}/seed-leases/release, and GET /v1/{baseid}/seed-leases/status[?leaseid=…], all requiring a combined base-scoped write:seeds grant. The first acquire/reset and first release/clear transition cost one flat op each; status, conflicts, exact retries, and repeated releases cost zero.
Rules and guarantees:
- Idempotent by
externalid.applyupserts each record by itsexternalid, so re-applying updates in place and never creates a duplicate — no twin records, even if a record already existed. resetrestores the baseline. It re-applies the declared records in place (fixing any a run mutated — e.g. a status a trigger flipped) and removes fixture-owned rows the fixture no longer declares. Useresetbetween eval runs for a guaranteed-clean start.exclusiverecordtypesmakesresetauthoritative for whole record types. Records an agent creates during a run carry no ownership marker, so a plain reset leaves them behind and they accumulate across runs. List the record types your evals write to andresetalso removes every record of those types the fixture didn't declare — regardless of who created it, including other fixtures' rows — plus any relationships pointing at the removed records. A record referenced by one of the fixture's declared relationships always survives, even if it isn't inrecords. Opt-in and reset-only (apply/clearare unchanged); a relationship an agent created between two kept baseline records still survives; a listed record type that doesn't exist on the base is a silent no-op at reset (existence is checked at promote). Choose exclusive types deliberately on a shared preview — the fixture becomes the single source of truth for them.clearis teardown. It deletes exactly the rows the fixture owns, and nothing else — a fixture records ownership via a reserved ownership marker (neverexternal_source), so teardown can never touch non-seed data.- Preview-only.
apply/reset/clearrun only on a preview base (like schema changes); they refuse production. The fixture definition promotes with the base like any other config, but the data ops stay preview-scoped. - No trigger side-effects. Applying/resetting a fixture is baseline setup — it does not fire triggers, so restoring data can't cascade.
- API-addressable. The three verbs are reachable over the REST API by fixture name, so a cloud eval harness holding a token scoped to
write:seedson the one preview base can reset the data itself. - Leases globally lock fixture data operations. While any seed lease is active, ordinary
apply,reset, andclearcalls for every fixture returnFIXTURETARGETIN_USE; fixture-definition config CRUD remains available.
Provider Adapters
Import tool definitions from any LLM provider as recordtypes (rekor providers import <provider>), export recordtypes as tool definitions (rekor providers export <provider>), or turn a provider-native tool call into a record (rekor providers import-call). Supported providers: openai, anthropic, google, mcp. Command forms and payload shapes in references/providers.md.
MCP Factory (custom toolsets)
Create purpose-built MCP servers from your base recordtypes. Each toolset serves domain-specific tools — agents see createinvoice, list_payments, not generic Rekor operations.
# 1. Author first-class Actions — one record_type + one operation each; the id is the tool name
rekor actions upsert get_invoice --base my-ws --record_type invoices --operation get
rekor actions upsert list_invoices --base my-ws --record_type invoices --operation list
rekor actions upsert create_payment --base my-ws --record_type payments --operation create
rekor actions upsert list_payments --base my-ws --record_type payments --operation list
# 2. Compose a curated toolset that references those Actions by id
rekor toolsets upsert invoicing-agent --base my-ws \
--name "Invoicing Agent" \
--action get_invoice --action list_invoices \
--action create_payment --action list_payments \
--relationship "invoice_payment:list" \
--batch "invoices:create,update" --batch "payments:create" --batch "invoice_payment:create" \
--sql-query
# Get the MCP connection URL (Streamable HTTP)
rekor toolsets url invoicing-agent
# → https://mcp.rekor.pro/t/invoicing-agent/mcp
# List all toolsets
rekor toolsets list --base my-ws
# Get toolset config (with resolved schemas)
rekor toolsets get invoicing-agent --base my-ws --resolved
# Delete a toolset
rekor toolsets delete invoicing-agent --base my-ws
Action reference format: --action <actionid> (or <actionid>=<surfacename> to rename it in this toolset). Author the Action first with rekor actions upsert <id> --recordtype X --operation Y — one recordtype + one operation, its id is the agent-facing tool name. An Action can also be a composite — rekor actions upsert <id> --config '{ "steps": [...] }' bundles several native writes (record create/update/delete + relationship create/delete) into one atomic tool with per-step guards (see references/mcp-factory.md → Composite Actions). Relationship spec format: reltype:op1,op2 (operations: create, list, delete) Batch spec format: recordtypeor_rel:op1,op2
Advanced control lives on the first-class Actions a toolset references. Author an Action with the shaping config — rekor actions upsert <id> --recordtype X --operation Y --config '{…}' (one recordtype + one op) — then reference it from the toolset (--action <id>, or an actions: [{ action, actionname?, descriptionoverride? }] entry in the toolset --config). The per-Action knobs, one line each (full grammar + JSON examples in references/mcp-factory.md):
- tool name — the Action
idis the agent-facing tool name; a toolset reference can rename it per-surface withactionnameor replace its description withdescriptionoverride. Names must be unique across the toolset. (Relationship tools, declared inline on the toolset, keep thename/namesmap.) filterablefields— expose chosen fields of alistAction as typed params (native arguments instead of a raw filter). Per field:param,match(exact/range/text/anyof/member/search),enum,pattern,optional,description. A declared field is required by default (it's the tool's question) —optional: trueopts out, and a range's two bounds are always optional.paramis a stem, not always the final param name:text→<param>contains,range→<param>min/max(after/beforefor dates),search→<param>search;exact/any_of/memberuse it verbatim.match: searchis the only match that reaches the rankedsearchoperator (and a field'sx-searchtuning) — seereferences/mcp-factory.md.- **
expose+default— alistAction's machinery params (filter/sort/limit/offset/fields) are hidden by default**, so the tool is pure semantics.expose<param>: trueopts one back in;default*sets the server-side value applied while a param stays hidden (a hiddenlimitdefaults to a generous page). writablefields— the write-side mirror on acreate/updateAction: an allowlist of exactly the fields it may set (least-privilege intent tools) that also generates a rich typeddataschema from the recordtype schema.basefilter— a Filter DSL predicate AND-merged server-side into every call of alistAction, invisible to the agent. Carves a narrow tool out of a broad recordtype (listactivepractitioners= the list Action +status = activebaked in) instead of exposing an optionalstatusparam the model must know to fill.listonly, over a native recordtype or a source-backed one whose list endpoint declareslocalfilters; not a security boundary. Use the value"$now"on adate-timefield with a range op for a predicate that stays current (starts_at gt $now= future only) instead of a literal date that ages — config-only, never a filter argument (seereferences/mcp-factory.md).precondition— a Filter DSL compare-and-set on acreate/updateAction, checked against the record's current state; a miss is a 409 and nothing changes. Invisible to the agent — turns a fragile read-then-write into one race-free call (e.g.book_slotonly if the slot is stillfree).binding— pick which named write binding of a proxy source's op a write Action dispatches to (see External Sources), keeping one canonical record_type whose writes fan out to several endpoints.
Connect agents to the toolset URL with a token scoped to exactly one base. The agent sees only the tools you configured — fully domain-specific, no Rekor concepts. For least-privilege, mint a token bound to the toolset in one step: rekor tokens create-for-toolset <slug> --base <db>. A toolset-bound token's authorization IS the toolset's tool surface — exactly those record_types and operations, relationships, batch, and SQL only if you enabled it, nothing else — so a leaked token can't reach beyond the tools you exposed, and you can rotate or revoke one per agent. For interactive setup, rekor toolsets upsert --mint-token also creates one, but its combined toolset-and-token output is not an automated credential-capture path.
For automated setup, run rekor tokens create-for-toolset <slug> --base <db> --token-only; see API Tokens below for the fail-closed capture recipe and JSON response shape.
Toolsets can only be created/modified in preview bases. Promote to production when ready; promotion is blocked if it would break a published toolset (a removed recordtype/rel-type, or a field its typed filters/writablefields/base_filter/precondition/bindings depend on) — a dry run lists conflicts first. mcp.rekor.pro/t/{slug}/mcp resolves the toolset from the base your token is scoped to, so connect with a production token for the promoted toolset or a preview-base token to sandbox-test the not-yet-promoted one (references/mcp-factory.md covers the resolution rules).
API Tokens
Create scoped tokens for agents, integrations, and CI/CD. Tokens can be restricted to specific bases, record_types, environments, and permissions. Tokens are hashed before storage — the raw value is shown only once on creation. Use --token-only when a script needs the secret; with --json, the unwrapped secret field is .token, not .data.token.
For automation, prefer --token-only: it validates the token shape and emits only the one-time secret, with no table, JSON, or MCP URL around it. Capture it without printing it or enabling shell tracing:
TOKEN="$(rekor tokens create-for-toolset <slug> --base <db> --token-only)"
case "$TOKEN" in rec_*) ;; *) echo "Token creation failed" >&2; exit 1 ;; esac
If a workflow must consume --json, Rekor CLI responses are already unwrapped: extract top-level .token, never .data.token, and fail closed rather than accepting null:
TOKEN="$(rekor tokens create-for-toolset <slug> --base <db> --json \
| jq -er '.token | strings | select(startswith("rec_"))')"
Mint probe/test/diagnostic tokens with --ttl so they expire on their own — a disposable token never needs a manual revoke or cleanup. Use --description to record what holds a token (which service, credential, or pipeline), so future you knows what breaks if it's revoked.
# Create a full-access token
rekor tokens create --name "my-key" --grants '[{"scope":{"bases":["*"]},"permissions":["*"]}]'
# Create a scoped token (read-only on one base)
rekor tokens create --name "client-a-reader" \
--grants '[{"scope":{"bases":["client-a"],"environments":["production"]},"permissions":["read:records","read:record_types"]}]'
# Capture the one-time secret for automation (validates rec_ and prints nothing else)
TOKEN="$(rekor tokens create --name "client-a-reader" --token-only \
--grants '[{"scope":{"bases":["client-a"]},"permissions":["read:records"]}]')"
# Disposable probe token — auto-expires, no cleanup needed (--ttl takes s/m/h/d)
rekor tokens create --name "probe" --ttl 10m \
--grants '[{"scope":{"bases":["*"]},"permissions":["*"]}]'
# Absolute expiry + a note about who holds the token
rekor tokens create --name "temp-key" \
--grants '[{"scope":{"bases":["*"]},"permissions":["*"]}]' \
--expires-at 2026-12-31T23:59:59Z \
--description "CI pipeline for repo X"
# List tokens (shows status, last_used_at, expires_at, description)
rekor tokens list
# Revoke a token (permanent)
rekor tokens revoke <token_id>
# Clean up stale tokens — selects by staleness ONLY (expired, or unused past the
# window), never by name. Previews first; revoking requires --yes.
rekor tokens prune # expired tokens only
rekor tokens prune --unused-since 30d # + tokens with no activity for 30 days
rekor tokens prune --unused-since 30d --yes
Revoke guardrail: revoking a token that was used in the last 7 days or is toolset-bound (a consumer token — an MCP connection likely holds it) is refused with a warning; pass --force to override. Do NOT --force (or prune with --include-bound) unless a human has confirmed nothing depends on the token — revocation is permanent and breaks live traffic immediately. Expired tokens revoke without friction.
Permissions: read:records, write:records, read:recordtypes, write:recordtypes, read:relationships, write:relationships, read:relationshiptypes, write:relationshiptypes, read:attachments, write:attachments, read:files, write:files, read:filetypes, write:filetypes, read:inboundwebhooks, write:inboundwebhooks, read:triggers, write:triggers, read:toolsets, write:toolsets, read:actions, write:actions, read:seeds, write:seeds, read:bases, write:bases, read:audit (read-only; grants change-history access, admin-gated, not implied by other grants), write:imports (bulk historical import; separate from write:records because an import commits without firing triggers, not implied by other grants), or * for all.
Scope fields: bases (required), record_types (optional — omit for all), environments (optional — production, preview, or omit for both).
Tokens enforce a privilege ceiling — you can only create tokens with equal or narrower scope than your own.
Secrets (vault)
Store credentials at the organization level — API keys, signing secrets, or credential-grade blobs (a client certificate, keystore, or service-account JSON). Secrets are encrypted at rest and always masked in responses. Reference them from source/trigger config (e.g. an executor's signing secret) or have an executor read one at runtime.
# Store a string secret
rekor secrets create --name stripe-key --value sk_live_xxx --tags billing
# Store a credential file (base64-encoded) with its type, and an expiry — e.g. a client certificate
rekor secrets create --name partner-cert --file ./partner.p12 \
--content-type application/x-pkcs12 --expires-at 2027-01-01T00:00:00Z
rekor secrets list # List (values masked)
rekor secrets list --expiring [--days 30] # Only secrets expiring/expired within the window
rekor secrets get <id> # Metadata (value masked)
rekor secrets rotate <id> --value <new> [--expires-at <iso>] # Install a new value (never auto-generates)
rekor secrets delete <id>
Set --expires-at (ISO-8601) on credentials that lapse — yearly certificates, time-bound tokens — then rekor secrets list --expiring surfaces what needs renewing before it breaks an integration. Expiry is informational; Rekor never auto-disables a secret.
Account & Diagnostics
# `rekor init` is retired — a rec_ key already carries its organization (see First-Time Setup)
rekor whoami # Show the authenticated identity
rekor status # Auth, connectivity, active org, worktree binding, CLI version
rekor update # Update the CLI to the latest published version
Staying up to date
Keep both the CLI and this skill current:
- Update the CLI — run
rekor update(published to npm asrekor-cli; verify withrekor --version). - Update this skill — run
mkdir -p .claude && npx skills add wayai-pro/rekor-skill -yto pull the latest published skill into.claude/.
The CLI checks for newer published versions of both in the background and prints a one-line notice when either is out of date; run the matching command above to update. Disable both checks with REKORNOUPDATECHECK=1 (also disabled when NOUPDATE_NOTIFIER or CI is set).
Reporting
File a bug or issue to the Rekor team, then follow it through to a fix. Reports are deduplicated: a repeat while work is pending merges into the existing report; the original reporter re-filing a resolved or non-finally-dismissed human report reopens it for another look unless its lifetime re-adjudication cap is exhausted.
If an update notice is showing (see [Staying up to date](#staying-up-to-date)), update and re-check before filing — the bug may already be fixed in a newer CLI or skill.
# File a report (deduplicated). --dedup-key collapses repeat occurrences of the same issue.
rekor report create --title "Upsert 500s on large docs" --description "..." \
[--severity high] [--steps "..."] [--error-message "..."] [--dedup-key "<stable-key>"] \
[--base-id <id>] [--record_type <name>] [--record-id <id>] # entity pointers aid investigation
# Track and verify your own reports
rekor report list [--status shipped] # your reports with titles, newest first (--status shipped = awaiting your check)
rekor report get <report_id> # title + description + status + the message thread
rekor report accept <report_id> # the shipped fix works → resolved
rekor report contest <report_id> --reason "still reproduces because ..." # the fix didn't work, or a dismissal is wrong → back to the team
The defect verification loop. ⚠️ Currently unreachable from the CLI. report list/get/accept/contest are user-scoped — they resolve the caller to the person who filed the report — and an API key identifies an organization, not a person, so these four commands are rejected. Filing (rekor report create) still works. Follow up on a filed report through whoever manages your account until the user-scoped surface returns. The rest of this paragraph describes the loop as designed:
After the team escalates a defect and a fix ships, your report moves to shipped — you'll get an email, and rekor status reminds you once (or find it by title with rekor report list --status shipped). Read rekor report get <id> (the original title and description, current status, and the team's note), then accept if it works or contest --reason "..." if it doesn't. Enhancements move directly to addressed when their issue closes because there is no defect fix to verify. You can also contest a dismissed report you believe is real — it routes back to the team. Contests are bounded (a cap, and the team may mark a dismissal final); past those, contact support. You can only list/read/act on human-filed reports you filed; agent-authored reports stay in the admin triage queue.
Output Format
Add --output json for machine-readable JSON output (default is table).
rekor records get invoices rec_abc --base my-ws --output json
Data Input
Any --data, --schema, --tools, or --operations flag accepts:
- Inline JSON:
'{"key":"value"}' - File reference:
@path/to/file.json
Environments
The rule lives in The Rekor Object Model: config writes only in preview, data writes anywhere, promotion human-run. The worked shape:
# 1. Create the preview
rekor bases create-preview my-base --name "add-invoices"
# 2. Make config writes against it
rekor record-types upsert invoices --base my-base--add-invoices --name "Invoices" \
--schema '{"type":"object","properties":{"amount":{"type":"number"}}}'
# 3. Ask the user to run the promotion (human-only — you cannot promote):
# rekor bases promote my-base --from my-base--add-invoices
If my-base is itself a preview (the free-plan shape — rekor bases create <id> --environment preview), step 1 still works — create-preview clones from a preview origin too — but step 3 does not: the promote is paid-plan gated, and a preview cloned from a preview promotes through its origin rather than straight to production. That clone is also created on the local analytics tier, permanently (see Bases → Analytics tier), so it cannot serve rekor sql, history or traversal unless you pass --analytics standard. Make the config writes directly against my-base and stop there.
Modeling Principles for Agent Consumption
The command reference above is the mechanics. This is how to combine them so an agent-backed app is reliable. The mechanics make almost any shape possible; these principles pick the shape that makes a fallible LLM agent succeed. They are domain-neutral — apply them whether you model invoices, appointments, or tickets. These shape the agent-facing tool surface; for how to back an entity (native, proxied, mirrored) and shape the integration edges, see Integration Modeling (canonical-first) above.
The one rule: model it so the right thing is the only easy thing to express, and the wrong thing is impossible. The gap between an agent that systematically drops steps and one that runs a clean full lifecycle is usually the shape of the schema and tools — not the prompt or the model.
- One intent = one atomic write. An action that takes N separate writes is N chances to drop one and leave the system half-updated. Collapse a single user intent into a single write; when one intent genuinely spans multiple records, use
rekor batchso they all commit or all roll back. - State lives on the entity — don't duplicate it. Keep each piece of state in exactly one place, on the entity it describes. Mirroring a status onto a second record forces the agent to update both in sync, recreating the multi-write problem. Link or query the source of truth instead of copying its fields.
- Expose the job, nothing more. An agent-facing tool should express the task and nothing else. Generated tools already hide the machinery (sort, limit, offset, field selection, the raw filter DSL) — see Tool design principles below for what that means for how you author Actions. The agent fills meaning via typed
filterablefieldson reads andwritablefieldson writes (the tool sees only the fields its job sets), not plumbing. - Guards belong in config, not arguments. Enforce invariants server-side where the agent can't forget them, not as instructions it must follow. A
preconditionmakes a state-dependent write race-free and invisible to the agent; schemarequired+ enums reject malformed input. The rejection message is the recovery channel — keep it legible and actionable so the agent self-corrects. - Narrow the input space. Every degree of freedom is a way to get it wrong. Constrain with enums (a closed set to pick from), typed
filterablefields(native params instead of free-form filter strings), stableexternalidkeys (idempotent upsert, no invented ids), andx-fkon writes (a reference must resolve to a real record). - Model for the fallible agent, not the ideal one. Agents skip steps, send empty strings for fields they didn't fill, and invent ids.
requiredrejects the missing field, enums reject the made-up value,x-fkrejects the invented reference,preconditionrejects the out-of-order write — each turns a silent corruption into a clear, correctable error. - Separate the operator surface from the agent surface. Keep the surface that authors config apart from the one that invokes it. Broad human/CI tokens design schemas and toolsets; toolset-bound tokens (
rekor tokens create-for-toolset) only call the curated tools and can't reach past them. Name tools by action and cardinality (bookslot,listinvoices,assign_payment) so the name itself tells the agent what the tool does.
Also: keep data canonical and self-describing — declare datetime fields format: date-time and set the record_type's x-timezone so every value carries its offset, and prefer readable enums over opaque codes, so a record can be reasoned about without outside context. And optimize the common path deliberately — make the single most frequent action one well-named tool call, accepting more friction on the rare one.
Tool design principles
How to shape the tools an agent actually calls. Rekor's defaults already implement these — the failure mode is widening a tool back out, so each rule below is about what not to add. The cost of a loose surface is paid by the weakest model that will ever hold the token: an optional parameter is one it may fill with an invented placeholder (null, none, sem_filtro, .*), which compiles to a real filter that matches nothing, reads as "no results" rather than an error, and drives a retry loop.
- A tool is an intent, not an endpoint. Name it for the job (
findpatientbyphone,bookappointment), and let it answer exactly one question. Two ways to look something up = two Actions, not one tool with two optional filters. - Machinery stays hidden.
filter,sort,limit,offset,fieldsare off by default; the tool's inputSchema is pure semantics. Opt one back in withexpose<param>: trueonly when the agent genuinely needs to drive it, and setdefaultlimit/default_fieldsinstead so the server decides. - Declared parameters are required; optionality is a choice you defend. A
filterable_fieldsentry is the tool's question, so it's required by default. Addoptional: truefor a genuine narrowing filter — and if a tool accumulates several, that's the signal to split it by intent instead. (Range fields are always optional: their two bounds can't be mandatory.) - Every discrete string parameter gets a closed shape. An
enum(pick from a set — best declared on the record_type schema so writes validate too) or apattern(one valid format, e.g.^\+[1-9][0-9]{6,14}$). An unconstrained optional string filter is where invented values come from. - What the agent shouldn't decide is injected server-side. A
basefilterscopes what a read can see; apreconditionguards a state-dependent write invisibly;writablefieldsbounds what a write may touch;external_source/bindingsroute it. None of these are agent-facing parameters. - Rejections teach the next move. The error message is the recovery channel (MCP clients drop
error.details), so it must name the parameter and the fix. This is why a bad value must fail loudly rather than compile into a filter that returns zero rows.
Counter-rule: split by intent, never by combinatorics. Three clearly-named tools beat one five-parameter tool; fifteen near-identical variants are worse than either, because tool selection degrades too.
Recipes: principle → the feature that realizes it
The principles above are agent-layer and backend-neutral; these are the specific toolset knobs that implement them — the lookup from intent to implementation. Each knob is detailed in the toolset section above.
- One tool = one intent; curate the surface — distinct
namesper operation (names: { "list": "searchinvoices" }), even two tools on one recordtype; a curatedfilterable_fieldslist. - Machinery (filter / sort / limit / offset / fields) — hidden by default;
expose<param>: trueopts one back in,default*sets the server-side value applied while it stays hidden. - Make a filter optional —
filterable_fields[].optional: true(declared fields are required by default; arangefield's bounds already are optional, andoptional: falseon one is rejected). - Constrain a closed set —
filterablefields[].enum, or anenumon the recordtype schema field (preferred — writes validate against it too, and the filter param derives it). - Constrain a structured id or code —
filterable_fields[].pattern(e.g.^pat-[0-9]+$); published to the tool schema and enforced at runtime. - Require a value at all — automatic: a declared
filterable_fieldsentry is required unless markedoptional: true(arangefield's two bounds are always optional), and a required param rejects blank / empty-selection input (the model can't fake compliance with""or[]). - Fail loud + actionable, never silent —
x-fkon write fields; the empty /enum/patternrejections on read params (read/write parity). - Scope a tool to a subset without an agent-facing filter —
base_filteron alistAction (invisible, always applied). - Check a tool surface against these principles —
rekor pushprints advisory tool-design warnings (every filter required, several optional filters on one tool, an unconstrained string filter, the rawfilterescape hatch left open alongside typed params). Advisory only: they never block a push. - Make a state-dependent write atomic + race-free — a
create/updatetool with aprecondition(compare-and-set; invisible to the agent; 409 on conflict). - Nudge the agent toward the right value —
filterable_fields[].description: name the field's kind, source, and when to use it; use placeholder shapes, never real data values.
One gotcha: an enum invites the agent to pick a value — right for a required parameter it should always fill, wrong for an optional: true one it should usually skip (a fixed set nudges it to choose rather than omit). On an optional filter prefer a pattern (or a plain free param); better still, if the field deserves an enum, ask whether it's really the tool's intent and should be required.
Best Practices
- Use external IDs for idempotent upserts — retry-safe, no duplicates.
- One base per context — keeps data isolated (per test run, per agent, per environment).
- Preview for schema changes — modify record_types, triggers, inbound webhooks in a preview base. Ask a human to promote.
- Batch for atomicity — when multiple writes must succeed or fail together.
- Relationships over nested data — link records instead of embedding. Enables traversal and flexible queries.
- Files for large or binary content — record data and relationship metadata are for structured JSON and are capped at ~1 MiB; inlining base64 blobs is rejected. Store PDFs, images, and other binary content as Files (
rekor files put <file_type> <path> --file <local>) and link them to records — or as simple record-scoped attachments (rekor attachments upload …). - Schema first — define the record_type schema before writing records. Validation catches bad data early.
- Query delay after writes — single-record reads (
records get,relationships get) reflect writes immediately;sql,records query,query-relationships, andsearchmay take a moment to catch up. If you need to query right after writing, wait briefly or use direct gets. - Set token expiration — use
--expires-atfor short-lived tokens. Revoke unused tokens promptly. - Save tokens immediately — the raw token is shown only once on creation. Store it securely before closing the terminal.