Skip to content

Agents and AI

Actuator is built for both humans and AI agents to operate the same surface. The REST API, the audit log, the evaluator, and the MCP server are peers — agents are not second-class consumers. This page consolidates the agent-relevant material into one orientation.

A quick map of what's below:

  • What surface do agents have? Scoped tokens with five capability levels, six action verbs, and a delegation chain that tops out at the human who minted the token — see Token model.
  • How do agents reason about a change before making it? A counterfactual preview endpoint that evaluates a hypothetical ruleset against a list of contexts — see Counterfactual preview.
  • How do agents propose changes safely? A two-phase propose / apply flow with version-drift detection — see Propose / apply.
  • How do agents connect? Either via REST or the first-party MCP server — see MCP server.

Token model

Every API token declares a scope along several dimensions:

DimensionValues
EnvironmentsSpecific env(s), all envs in a project, or all envs in the org
ResourcesSpecific keys, key prefixes (payments.*), or all
Actionsread, propose, write, toggle, promote, delete
Capability levelobserver, proposer, operator, maintainer, admin
Rate limitRequests per minute per token (default 60)
TTLAuto-expires (1 hour to 90 days; renewable)
Approval requiredWhether mutations need human approval before taking effect
IP allowlistOptional CIDR range restriction

The five capability levels are pre-baked scope templates that reduce misconfiguration:

  • Observer — read-only. Audit queries, flag/config reads, evaluation tracing. The default for agents whose job is to investigate, summarize, or report.
  • Proposer — read + create proposals. Cannot mutate directly; every change goes through the approval workflow. The safe default when an agent acts on behalf of a user with limited authority, or when an operator wants review on every agent action.
  • Operator — toggle existing flags, adjust rollout percentages, edit existing rules. Cannot create or delete flags, cannot change approval settings. The on-call assistant profile.
  • Maintainer — full CRUD on flags and configs. Cannot change RBAC, rotate tokens, or modify approval rules. Suitable for agents that manage flag lifecycle (creation, cleanup, scheduled changes).
  • Admin — everything. Intended for humans, not agents. Granting Admin to an agent is supported but requires an explicit override and generates a prominent warning.

A safe default agent token: read + propose on production, write + toggle on staging, 7-day TTL, 60 req/min.

Delegation

A user can mint short-lived tokens scoped at-or-below their own permissions and hand them to agents. Token minting is itself an audited action. Delegated tokens inherit a ceiling from the delegator — agents cannot escalate beyond their human's permissions, and revoking the human cascades-revoke every token they delegated. This is the default pattern for giving an engineer's personal agent temporary elevated access without a permanent configuration change.

The audit row for every agent-originated mutation carries the full chain: actor_token_iddelegator_user_id → (if approval-gated) approver_user_id.

Counterfactual preview

POST /api/v1/envs/:envId/evaluate/preview evaluates a hypothetical ruleset against a list of contexts and returns both the live and preview resolution side-by-side, so an agent can reason about blast radius before committing a change.

http
POST /api/v1/envs/{envId}/evaluate/preview
Content-Type: application/json

{
  "spotCheck": [
    { "userId": "u_42", "plan": "enterprise" },
    { "userId": "u_99", "plan": "free" }
  ],
  "ruleset": {
    "flags": [
      {
        "key": "ui.theme",
        "type": "string",
        "rules": [],
        "defaultValue": "midnight"
      }
    ]
  },
  "verboseReason": false
}

The response carries the env's current liveVersion plus, for each context, the live resolution and the preview resolution:

json
{
  "environmentId": "9f7a32b5-1234-4abc-9def-...",
  "liveVersion": 142,
  "spotCheck": [
    {
      "context": { "userId": "u_42", "plan": "enterprise" },
      "live":    { "ui.theme": { "value": "classic",  "defaultValue": "classic",  "reason": { "kind": "default" } } },
      "preview": { "ui.theme": { "value": "midnight", "defaultValue": "midnight", "reason": { "kind": "default" } } }
    },
    {
      "context": { "userId": "u_99", "plan": "free" },
      "live":    { "ui.theme": { "value": "classic",  "defaultValue": "classic",  "reason": { "kind": "default" } } },
      "preview": { "ui.theme": { "value": "midnight", "defaultValue": "midnight", "reason": { "kind": "default" } } }
    }
  ]
}

Notes:

  • The transient ruleset is subject to the same save-time validation as a write would be (cycle detection, regex compilation, JSON Schema, type compatibility) — invalid rulesets return 400 with the same errors POST /flags would have produced.
  • The preview is not stored anywhere server-side and does not bump any version.
  • A request is capped at 50 spot-check contexts. The endpoint is for spot-checking specific personas (the CEO, your test fixtures, named accounts), not for scanning a user base.
  • asOf and ruleset are mutually exclusive; preview is always against the supplied ruleset.

The dashboard's "Preview impact" panel on the flag detail page is a thin client over this endpoint.

Propose / apply

The propose / apply flow is the durable, auditable equivalent of "agent suggests, human approves." It builds on the preview endpoint above: the agent captures the blast radius at propose time, and the applier verifies the live ruleset hasn't drifted before letting the change land.

Lifecycle

pending (TTL: 1h default, 24h max)

    ├──► applied   (apply handler committed the diff)
    ├──► cancelled (explicit reject by proposer or operator)
    └──► expired   (background sweeper after expiresAt)

A proposal is single-use: any second apply or cancel against a non-pending row returns 410 proposal_gone, and an apply against a cancelled row returns 410 too.

Propose

http
POST /api/v1/proposals
Content-Type: application/json

{
  "envId": "9e1f…",
  "kind": "set_default_value_flag",
  "resourceKey": "ui.theme",
  "diff": { "defaultValue": "midnight" },
  "spotCheck": [
    { "userId": "u_42", "plan": "enterprise" }
  ],
  "reason": "switch default theme; 0 of 1 sampled contexts flip",
  "expiresInSeconds": 3600
}

spotCheck is capped at 50 entries. expiresInSeconds is optional; default 3600 (1h), max 86400 (24h). resourceKey identifies the target primitive — the resource type is implied by kind and validated server-side.

Returns 201 Created with Location: /api/v1/proposals/{id} and a Proposal body that carries:

  • liveVersion — the env's version at propose time. Compared at apply time for drift detection.
  • blastRadius — the materialized output of running /evaluate/preview against spotCheck with the proposed ruleset. Each entry has {context, live, preview} envelopes.
  • expiresAt — absolute timestamp; the sweeper uses this.
  • status: "pending", plus the proposer attribution (proposerTokenId or proposerUserId).

Error responses:

  • 409 in_use for delete_segment of a segment referenced by another flag/config; fields lists referrers as <resourceType>.<resourceKey> filtered by the caller's scope.
  • 503 ruleset_not_cached if the env has no live blob yet (a freshly created env hits this until its first write).

Kinds (the verb vocabulary)

kind selects the mutation; each kind names a concrete verb the apply path switches on.

ResourceKinds
Flagset_default_value_flag, set_rules_flag, kill_flag, delete_flag
Configset_default_value_config, set_rules_config, delete_config
Segmentset_rules_segment, delete_segment

diff is kind-specific:

  • set_default_value_*{ "defaultValue": <typed> }
  • set_rules_*{ "rules": [<rule>, ...] } (segments also accept allowList?, denyList?)
  • kill_flag{} (boolean flags only; the verb is "set defaultValue=false, rules=[]")
  • delete_*{}

Get

http
GET /api/v1/proposals/{id}

Returns the same Proposal body propose returns, useful when the agent host's context window has been compacted and you need to re-read the blast radius before applying. Cross-tenant access collapses to 404.

Apply

http
POST /api/v1/proposals/{id}/apply

Inside a single transaction, the apply handler:

  1. Takes a row-level lock on the proposal, asserting status = 'pending'.
  2. Reads the env's current version.
  3. Compares it to the captured liveVersion.
    • Mismatch: returns 409 version_drift with body {liveVersion, proposedVersion}. The proposal stays terminal-eligible (still pending until the sweeper expires it); the agent is expected to re-propose against the new live state, which captures a fresh blastRadius.
    • Match: executes the diff via the canonical write path (audit + cache + version bump). The audit row carries reason="proposal:<id>"; the proposal back-links via appliedAuditId.
  4. Marks the proposal applied with resolvedAt, resolverTokenId or resolverUserId, appliedAuditId, and appliedVersion set to the new env version.

Returns 200 OK with:

json
{
  "proposalId":     "...",
  "status":         "applied",
  "appliedVersion": 143,
  "appliedAuditId": "...",
  "resolvedAt":     "2026-04-29T14:33:00Z"
}

Error responses:

  • 409 version_drift — env was written between propose and apply.
  • 409 in_usedelete_segment apply finds new referrers that landed between propose and apply.
  • 410 proposal_gone — proposal is already applied or past its expiresAt.

The version-drift check is the load-bearing safety property: two concurrent applies can't both succeed — the second sees the new env version and the comparison fails.

The propose action gates POST /proposals; the apply action gate matches the underlying CRUD verb (write for set/kill kinds, delete for delete kinds), so a Proposer-level token can stage any change but only an Operator+ token can apply.

Cancel

http
POST /api/v1/proposals/{id}/cancel
Content-Type: application/json

{ "note": "human reviewed; not shipping this change" }

Body is optional — {} cancels without a note. Reachable by:

  • the original proposer (regardless of action scope), so an agent can withdraw its own pending proposal; or
  • anyone holding the action the apply would have required (so an Operator+ token can reject any pending row in scope).

Returns the canceled Proposal row with status='cancelled', resolvedAt, the resolver attribution, and resolverNote (also copied to the proposal's reason if set).

Error responses:

  • 410 proposal_gone — proposal is already applied, cancelled, or past its expiresAt.

Expire

A background sweeper inside the server flips status='expired' for any pending row past its expiresAt. Expired proposals can't be applied; the agent re-proposes if the human takes too long.

Audit chain

Every mutation — agent or human, write or apply — is recorded with the full accountability chain:

ColumnMeaning
actor_typeuser | api_token | agent_token | system
actor_idToken id (or user id, for human actions)
delegator_user_idThe human who minted the token, if delegated
approver_user_idThe human who approved the apply, if gated
resource_type, resource_key, resource_idWhat changed
previous_value, new_value, diffThe change payload
reasonFree-text — e.g., "incident #4821 mitigation" or "proposal:4f2a"

The dashboard's audit page filters by actor type, actor, time range, and resource. Every column above is queryable via GET /api/v1/orgs/:slug/audit.

Proposal lifecycle is also audited

Every proposal transition writes its own audit row with resourceType=proposal and one of the following actions:

  • proposal.created — when POST /proposals succeeds.
  • proposal.applied — when POST /proposals/{id}/apply commits.
  • proposal.cancelled — when POST /proposals/{id}/cancel flips it.
  • proposal.expired — when the sweeper flips a TTL'd row.

This timeline is independent of the data-change rows. When a proposal applies, the underlying primitive's audit row carries reason="proposal:<id>" and the proposal-side row's appliedAuditId back-links — so two queries reconstruct the full story:

  • audit?resourceType=proposal&resourceId=<id> — the proposal's lifecycle (created → applied / cancelled / expired).
  • audit?resourceType=flag&resourceKey=<key> (or config / segment) — the data-change timeline; rows produced by an apply are tagged with reason="proposal:<id>".

MCP server

A first-party Model Context Protocol server, distributed as the @actuator/mcp package (npx @actuator/mcp). The shipped tool surface mirrors the REST endpoints — agents that prefer raw HTTP can call those directly with no loss of capability.

CategoryTools
Navigationlist_organizations, list_projects, list_environments, list_flags, list_configs, list_segments
Describedescribe_flag, describe_config, describe_segment
Evaluationevaluate_for_context, lookup_flags_for_target, get_ruleset_version
Auditaudit_query, describe_audit_event
Propose (one verb per kind)propose_set_default_value, propose_set_default_value_config, propose_set_rules_flag, propose_set_rules_config, propose_set_rules_segment, propose_kill_flag, propose_delete_flag, propose_delete_config, propose_delete_segment
Apply / cancel / inspectapply_proposal, cancel_proposal, describe_proposal
Direct createcreate_flag, create_config, create_segment
Metadata editsupdate_flag_metadata, update_config_metadata, update_segment_metadata
Append-rule helpersappend_flag_rule, append_config_rule, append_segment_rule

The propose tools are kind-specific so each one carries a typed schema for its diff — the agent host can validate the call locally before sending. Every propose tool returns a proposalId and the materialized blast radius; the canonical pattern is "propose → present blast radius to the human → call apply_proposal(proposalId) on approval."

create_* and the metadata/rule-append helpers exist alongside the propose surface for low-risk operations where round-tripping a human approval would be friction with no safety win — they call the underlying POST /flags / PATCH /flags/{key} endpoints directly under the calling token's write action gate. A token without write falls through to using the propose tools.

Every mutation tool respects the calling token's capability level and the env's approval-gate configuration. An Observer-level token calling a write tool receives a structured permission error pointing it at the matching propose_* tool instead.

See also