Skip to main content
Glama
granted
by wayneColt

granted

A time-bound, single-use grant that lets an agent carry out a decision a human already made — on a clock, with a receipt from the substrate, and never silently.

"you give your agent a task, then walk away and get a coffee, only to come back and find the agent got stuck on an approval on the first step and has made no progress. As a result, people often give in and set their agents to 'auto-approve', or --dangerously-skip-permissions, which is, obviously, unsafe."

— the cloudflare-os README

Cloudflare's answer is deferred approval: the agent proceeds against simulated results and the human approves later, in bulk. That fits a company where the human is back in minutes. granted is the answer for the owner-operator whose median time-to-keystroke is measured in days: approve once, in advance, with a deadline; the machine discharges it on the clock; the ledger — not the tool — confirms it; and silence is not a permitted outcome.

Not affiliated with Cloudflare. Python 3.11+, standard library only.

ci

30-second setup

pip install git+https://github.com/wayneColt/granted
granted mint --type report --id 2026-09-30 --by "owner keystroke" \
             --not-after 2026-10-01T00:00:00Z --allow send
granted status --type report --id 2026-09-30
granted discharge --type report --id 2026-09-30 --action send --exec ./send_report.sh

The last line prints one of discharged, refused, uncertain, expired with a reason, and exits 0, 3, 4 or 5 to match. Run it again: refused: consumed. Grants are files in ~/.granted/grants/; the ledger is ~/.granted/ledger.jsonl. GRANTED_HOME, GRANTED_STORE, GRANTED_LEDGER move them.

Related MCP server: dingdawg-agent-wallet

Five clauses

  1. A decision is a grant. One object, once. The human's decision becomes one file with the object's type and id in it. There is no grant for "things like this".

  2. A grant carries its deadline. not_after is required; not_before is optional. A grant with no deadline is a standing permission, and the validator refuses it.

  3. The executor discharges inside the grant and never outside it. It may never mint. The library has no path from an executor, an MCP client, or a runner to a new grant. Minting is a CLI verb, run by a hand.

  4. Every outcome is loud. discharged | refused | uncertain | expired, each with a reason from a closed vocabulary, each written to the ledger as an outcome row, each returned — never raised — to the caller. A refusal leaves a row. An expiry leaves a row.

  5. Confirmation comes from the substrate. An intent row before the action, a result row the executor writes after it, and the grant's own consumed flag and receipt, re-read from disk. The executor's return value alone confirms nothing: success with no result row is uncertain.

What stays human

The primitive is for decisions already made. It does not make them, rank them, or infer them. These verbs never ride a grant by default, and nothing here will discharge them unless a human mints for that exact object, on purpose:

  • hire

  • negotiate

  • sign

  • pay

  • first contact with no prior thread

not_scope exists so the grant can say so in words the approver reads.

The grant

{
  "kind": "granted/1",
  "decision": "APPROVE_ONCE",
  "object": {"type": "report", "id": "2026-09-30"},
  "scope": "send the weekly report to the list it went to last week",
  "not_scope": ["any other recipient", "any attachment not already in the draft"],
  "allowed_actions": ["send"],
  "not_before": null,
  "not_after": "2026-10-01T00:00:00Z",
  "minted_by": "owner keystroke",
  "minted_at": "2026-09-20T12:00:00Z",
  "consumed": false,
  "consumed_at": null,
  "receipt": null
}

field

meaning

decision

APPROVE_ONCE: spent on the first discharge. APPROVE_SESSION: many discharges of the same object inside one window; spent at the first outcome that is not discharged, or at not_after.

object

{type, id}. The grant is about this and nothing else.

scope, not_scope

words for the human; the library does not parse them

allowed_actions

if non-empty, a discharge must name one of them

not_before, not_after

ISO 8601 with a zone. Naive stamps are refused.

minted_by

the hand. A string, not an identity system.

consumed, consumed_at, receipt

written by the store; the receipt names the discharge that spent it and its outcome

The JSON Schema is at docs/grant.schema.json. One file per grant; the store finds the live one for an object and reports none | consumed | not_yet | expired | unreadable when there is none.

The state machine

find grant ──none/consumed/not_yet/unreadable──▶ refused
    │        └──expired──────────────────────────▶ expired
    ▼
write intent row ──fails──▶ refused (ledger_unwritable; nothing runs)
    ▼
claim grant (receipt: pending) ──lost──▶ refused
    ▼
executor(grant)
    ├─ raises Uncertain ─────────────────────▶ uncertain  (spent)
    ├─ raises ExecutorFailed / anything ────▶ refused    (spent: effects unknown)
    ├─ returns falsy ───────────────────────▶ refused    (executor_declined; grant stays live)
    └─ returns truthy
         ▼
       ledger.confirms(object)? no ─────────▶ uncertain  (no_result_row; spent)
         ▼ yes, and the row says ok
       settle receipt, re-read grant ──mismatch─▶ uncertain (spent)
         ▼
       discharged  (APPROVE_ONCE: spent)

Once the executor has been called, the grant is spent whatever happens next — except when the executor positively declines, which means "nothing done, try me later". Retrying anything else is a human act: mint again. The full reason vocabulary is granted.discharge.REASONS.

The executor

The library never knows how to send an email or deploy anything. The host supplies executor(grant):

it

means

returns truthy

claims success — and must have written a result row

returns falsy

did nothing; the grant stays live

raises granted.Uncertain

cannot say whether it happened

raises granted.ExecutorFailed

failed part-way; effects unknown

raises anything else

a bug; treated like a failure

from granted import Store, Ledger, discharge

store, ledger = Store("~/.granted/grants"), Ledger("~/.granted/ledger.jsonl")

def send_report(grant):
    message_id = mailer.send(draft_for(grant.object_id))      # the host's code
    ledger.result(grant.object, ok=True, receipt={"message_id": message_id})
    return True

outcome = discharge(store, ledger, {"type": "report", "id": "2026-09-30"}, send_report, action="send")
print(outcome)            # discharged: confirmed (report/2026-09-30) -- result row line 42

The shell executor behind granted discharge --exec writes the result row itself (exit status, stdout and stderr tails). Exit 0 claims success; exit 75 (EX_TEMPFAIL) declines; any other exit is a failure; a --timeout is uncertain. The command sees GRANTED_OBJECT_TYPE, GRANTED_OBJECT_ID, GRANTED_DISCHARGE_ID, GRANTED_GRANT_PATH, GRANTED_LEDGER, GRANTED_NOT_AFTER in its environment.

CLI

granted mint      --type T --id I --by "who" --not-after ISO [--not-before ISO] [--decision D] [--scope ..] [--not-scope ..] [--allow ..]
granted status    --type T --id I
granted discharge --type T --id I --exec "cmd" [--action A] [--timeout S]
granted ledger    [--tail N] [--id I]
granted lag       [FILE|-]
granted mcp       [--executor NAME=COMMAND ...]

--json on any of them. python -m granted works from a clone without installing.

MCP server

granted mcp --executor send_report=./send_report.sh

stdio, JSON-RPC 2.0, no third-party code. Implements initialize, ping, tools/list, tools/call. Three tools:

tool

does

grant_status

live | none | consumed | not_yet | expired | unreadable, the grant on file, the last ledger rows

grant_discharge

discharge through an executor the host registered by name; returns one of the four outcomes

ledger_tail

the last N rows

There is deliberately no mint. Minting is the human's act and lives in the CLI. An agent on this server can learn, discharge, and read back; it cannot widen its own permissions and cannot hand the executor a command of its own.

Client configuration, for a host that takes the usual shape:

{"mcpServers": {"granted": {"command": "granted", "args": ["mcp", "--executor", "send_report=./send_report.sh"]}}}

The clock

adapters/systemd/ is a granted-discharge@.timer + .service pair and a runner. The timer ticks every 15 minutes; the grant's own window decides; the runner stops the timer once there is nothing left to ask. uncertain is left as a failed unit on purpose.

Measuring the lag

The motivation is a number. Keep a JSONL of {"raised": ISO, "answered": ISO} for every time an agent waited on a keystroke, then:

$ granted lag asks.jsonl
n=10 open=1 bad=1 p50=10.0h p80=72.0h max=240.0h

Nearest-rank percentiles. open is the count still waiting. If p50 is minutes, use deferred approval. If it is days, mint.

Status

Surface

Status

Core: schema, store, ledger, discharge

yes; 63 tests under python -m unittest

CLI: mint, status, discharge, ledger, lag, mcp

yes

MCP server (stdio; initialize, ping, tools/list, tools/call)

yes; no mint by design

systemd adapter

yes (unit, timer, runner)

cloudflare-os Gatekeeper

design note + TypeScript skeleton; not compiled or deployed

Door (signed admission endpoint)

contract only

Signed intents in the CLI / MCP path

not in 0.1.0; both trust the process boundary

Store across hosts

not in 0.1.0; a directory on one disk, one lock file per grant

Windows

untested

Build

git clone https://github.com/wayneColt/granted
cd granted
python -m unittest -v
python -m granted --help

No dependencies. License: Apache-2.0 OR MIT.

What this is not

  • Not an approval queue. Nothing waits here; a grant exists or it does not.

  • Not an identity system. minted_by is a string the human types. The door contract in docs/DOOR.md says where signatures go; this release has none.

  • Not a scheduler. The systemd timer is a tick; the deadline is the grant's.

  • Not a policy engine. It cannot tell a safe action from a dangerous one. That is what the human did when they minted, and what not_scope is for.

Release drafts (X, blog) live in RELEASE_DRAFTS.md and wait for an operator keystroke.

Available Tools

3 tools
grant_dischargeA

Carry out the decision a human already recorded for this object, through an executor the host registered by name. Returns one of discharged | refused | uncertain | expired with a reason. Cannot mint; cannot run anything the host did not register.

Inputs: id (required), type (required), executor (required), action.

grant_statusB

Is there a live grant for this object? Returns live | none | consumed | not_yet | expired | unreadable, the grant on file, and the last ledger rows.

Inputs: id (required), type (required).

ledger_tailC

The last N ledger rows (intent, result, outcome), optionally for one object id.

Inputs: n, object_id.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

    TDQS

    A3.7/5.0

    Scored across 3 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: grant_status checks the current state of a grant, grant_discharge executes a recorded human decision, and ledger_tail provides raw ledger history. Even though status and ledger_tail both touch ledger rows, their focus and output differ enough to avoid confusion.

    Naming Consistency4/5

    All tool names use lowercase snake_case and follow a consistent 'domain_action/noun' style, such as grant_status, grant_discharge, and ledger_tail. They deviate from a strict verb_noun pattern but are still predictable and internally consistent.

    Tool Count5/5

    With only three tools, the surface is tightly scoped to the server's apparent purpose: checking grant state, discharging grants, and auditing the ledger. Each tool covers a distinct operation and none feels redundant or unnecessary.

    Completeness5/5

    The server intentionally does not create or mint grants—those decisions are recorded by humans and executed via grant_discharge. Within that scope, status checking, execution, and ledger auditing form a complete workflow with no obvious dead ends.

    Maintenance

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers