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.
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.shThe 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.
- 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".
- A grant carries its deadline.
not_afteris required;not_beforeis optional. A grant with no deadline is a standing permission, and the validator refuses it. - 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.
- Every outcome is loud.
discharged | refused | uncertain | expired, each with a reason from a closed vocabulary, each written to the ledger as anoutcomerow, each returned — never raised — to the caller. A refusal leaves a row. An expiry leaves a row. - Confirmation comes from the substrate. An
intentrow before the action, aresultrow the executor writes after it, and the grant's ownconsumedflag and receipt, re-read from disk. The executor's return value alone confirms nothing: success with no result row isuncertain.
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.
{
"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.
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 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 42The 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.
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.
granted mcp --executor send_report=./send_report.shstdio, 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"]}}}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.
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.0hNearest-rank percentiles. open is the count still waiting. If p50 is minutes, use deferred approval. If it is days, mint.
| 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 |
git clone https://github.com/wayneColt/granted
cd granted
python -m unittest -v
python -m granted --helpNo dependencies. License: Apache-2.0 OR MIT.
- Not an approval queue. Nothing waits here; a grant exists or it does not.
- Not an identity system.
minted_byis a string the human types. The door contract indocs/DOOR.mdsays 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_scopeis for.
Release drafts (X, blog) live in RELEASE_DRAFTS.md and wait for an operator keystroke.