See an agent refused at ratify
Smeldr lets an AI agent propose a decision, but only a person can ratify it. In this tutorial you watch that happen on your own server: an agent with a token of its own tries to ratify a decision and is refused, a person without the right is refused for a different reason, the agent can't be given the right either, and then you ratify the decision yourself.
It takes a few minutes and starts where Getting started ends: the server is running, your own token is in .smeldr-cli.env, and your decisions are D1 and D2. Work in the same directory as before.
Where the two shells differ, a step shows the bash form first, then the PowerShell form, which goes on from Getting started's PowerShell steps. Commands shown once run unchanged in bash and in Windows PowerShell, with the files named as in Getting started. cmd.exe is not covered. The steps were run as written on Linux and in Windows PowerShell on Windows 11. Smeldr's automated tests also run the bash steps as written on macOS, on Apple silicon and on Intel, on the macOS versions the tests use.
Get ready
If you are in a new terminal, load your token and define the mcp function from Getting started again:
. ./.smeldr-cli.env
mcp() {
curl -s http://localhost:8080/mcp/message \
-H "Authorization: Bearer $SMELDR_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data @"$1"
}
In PowerShell:
Get-Content .smeldr-cli.env | ForEach-Object { $k, $v = $_ -split '=', 2; Set-Item "env:$k" $v }
function mcp($file) {
# The answer is saved and read back as UTF-8, so the console shows it as the server sent it.
curl.exe -s http://localhost:8080/mcp/message `
-H "Authorization: Bearer $env:SMELDR_TOKEN" `
-H 'Content-Type: application/json' `
-H 'Accept: application/json, text/event-stream' `
--data "@$file" -o answer.json
Get-Content answer.json -Encoding UTF8
}
Propose a decision and place it
Propose a new decision and place it in the Organization, as you did in Getting started:
cat > propose3.json <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_decision","arguments":{
"title":"Agents never merge to main",
"scope":"engineering",
"body":"An agent opens a branch and asks for review. A person merges."}}}
EOF
mcp propose3.json
sed 's/"D1"/"D3"/' place.json > place3.json
mcp place3.json
In PowerShell:
@'
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_decision","arguments":{
"title":"Agents never merge to main",
"scope":"engineering",
"body":"An agent opens a branch and asks for review. A person merges."}}}
'@ | Set-Content propose3.json
mcp propose3.json
(Get-Content place.json) -replace '"D1"', '"D3"' | Set-Content place3.json
mcp place3.json
The new decision is D3, with the status proposed.
Make a token for an agent
An agent gets a token of its own, classed as an agent. Create one and keep its answer in a file:
./smeldr-cli token create my-agent editor 30 --class agent --reason "my agent" > agent.json
In PowerShell:
./smeldr-cli token create my-agent editor 30 --class agent --reason "my agent" | Set-Content agent.json
The file holds the new token, its token_id and the identity it belongs to. The token is shown once and can't be read again, so don't print it. Read it and its id into the shell, the way an agent's configuration would hold them:
AGENT_TOKEN=$(grep -o '"token": "[^"]*"' agent.json | cut -d'"' -f4)
AGENT_ID=$(grep -o '"token_id": "[^"]*"' agent.json | cut -d'"' -f4)
In PowerShell:
$agent = Get-Content agent.json -Raw | ConvertFrom-Json
$AGENT_TOKEN = $agent.token
$AGENT_ID = $agent.token_id
Let the agent work on decisions
A token with no grant can't read D3 at all: the server answers every call on it with forbidden, of the refusal type capability.denied. Give the agent the editor role, which lets it read, write and manage items, and holds no right to ratify:
./smeldr-cli grant "$AGENT_ID" editor --rev 0 --reason "works on decisions"
{
"id": "<id>",
"identity_rev": 1
}
The agent tries to ratify
Now send the agent's call. It is the same transition_item call any MCP client makes, with the agent's token:
cat > ratify3.json <<'EOF'
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"transition_item","arguments":{
"type_name":"Decision","slug":"D3","to_state":"ratified","rev":0}}}
EOF
curl -s http://localhost:8080/mcp/message \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data @ratify3.json
In PowerShell:
@'
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"transition_item","arguments":{
"type_name":"Decision","slug":"D3","to_state":"ratified","rev":0}}}
'@ | Set-Content ratify3.json
curl.exe -s http://localhost:8080/mcp/message `
-H "Authorization: Bearer $AGENT_TOKEN" `
-H 'Content-Type: application/json' `
-H 'Accept: application/json, text/event-stream' `
--data "@ratify3.json" -o answer.json
Get-Content answer.json -Encoding UTF8
The server refuses:
data: {"jsonrpc":"2.0","id":2,"error":{"code":-32001,"message":"forbidden: an agent token never ratifies, supersedes or amends a Decision; a human approver does","data":{"class":"refusal","id":"ratify.automated_class","rule":"D142 pt 5, T884 (places path: D145, D146)","type":"https://smeldr.dev/problems/ratify.automated_class","values":{"token_class":"agent"}}}}
The refusal type is ratify.automated_class, and token_class says why: the token belongs to an agent. The type address opens a page about the refusal on smeldr.dev, and rule names the record in Smeldr's own design where the rule was decided. smeldr-cli transition with the agent's token gets the same answer. D3 is still proposed.
A person without the right
Compare a token of class human, a person, with the same editor role:
./smeldr-cli token create a-person editor 30 --class human --reason "a person" > person.json
PERSON_TOKEN=$(grep -o '"token": "[^"]*"' person.json | cut -d'"' -f4)
PERSON_ID=$(grep -o '"token_id": "[^"]*"' person.json | cut -d'"' -f4)
./smeldr-cli grant "$PERSON_ID" editor --rev 0 --reason "works on decisions"
curl -s http://localhost:8080/mcp/message \
-H "Authorization: Bearer $PERSON_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data @ratify3.json
In PowerShell:
./smeldr-cli token create a-person editor 30 --class human --reason "a person" | Set-Content person.json
$person = Get-Content person.json -Raw | ConvertFrom-Json
$PERSON_TOKEN = $person.token
$PERSON_ID = $person.token_id
./smeldr-cli grant $PERSON_ID editor --rev 0 --reason "works on decisions"
curl.exe -s http://localhost:8080/mcp/message `
-H "Authorization: Bearer $PERSON_TOKEN" `
-H 'Content-Type: application/json' `
-H 'Accept: application/json, text/event-stream' `
--data "@ratify3.json" -o answer.json
Get-Content answer.json -Encoding UTF8
data: {"jsonrpc":"2.0","id":2,"error":{"code":-32001,"message":"forbidden: transition proposed→ratified on Decision requires operation \"ratify\"","data":{"class":"refusal","id":"transition.gate","rule":"D34, D40","type":"https://smeldr.dev/problems/transition.gate","values":{"from":"proposed","operation":"ratify","to":"ratified","type":"Decision"}}}}
The person is refused too, with a different type: transition.gate, because the token holds no right to ratify. That person could be given the right. The agent is refused for what it is, so no grant changes its answer.
The agent can't be given the right
Try to give the agent the approver role, which holds the right to ratify. The agent's identity now has one grant, so name its version with --rev 1:
./smeldr-cli grant "$AGENT_ID" approver --rev 1 --reason "try"
smeldr-cli: MCP error -32001: forbidden: an agent token never holds ratify: role "approver" holds it
Handing it the right for this one decision is refused the same way. delegate_item gives a role over one item for a limited time:
cat > delegate3.json <<EOF
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"delegate_item","arguments":{
"token_id":"$AGENT_ID","role":"item-approver","operation":"ratify","type":"Decision","id":"D3"}}}
EOF
mcp delegate3.json
In PowerShell:
@"
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"delegate_item","arguments":{
"token_id":"$AGENT_ID","role":"item-approver","operation":"ratify","type":"Decision","id":"D3"}}}
"@ | Set-Content delegate3.json
mcp delegate3.json
data: {"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"forbidden: an agent token never holds ratify: role \"item-approver\" holds it","data":{"class":"refusal","id":"grant.automated_holds_ratify","rule":"D142, T971","type":"https://smeldr.dev/problems/grant.automated_holds_ratify","values":{"role":"item-approver","token_class":"agent"}}}}
The agent's grants are still only editor:
./smeldr-cli grant list "$AGENT_ID"
You ratify it
Now ratify D3 with your own token, as the person who holds that right here:
./smeldr-cli transition Decision D3 --to ratified --rev 0
{
"awaits_action": false,
"id": "...",
"last_actor": "...",
"rev": 1,
"slug": "agents-never-merge-to-main",
"standing": "holds",
"status": "ratified"
}
The same decision the agent could not ratify now holds. When you are done, delete the two files that hold tokens:
rm agent.json person.json
In PowerShell:
Remove-Item agent.json, person.json
What this shows
- A token classed as an agent is refused when it tries to ratify a Decision, whatever roles it holds. A token classed as a job is refused the same way.
- A role that holds the right to ratify can't be granted or delegated to an agent's token.
- A person ratifies, with a person's token and the right over the decision's place.
The server refuses the act, and the decision's history has no entry for the attempt. It does not read code or a pull request against your decisions. Smeldr does not hold the merge: GitHub does, through a required status check that Smeldr sets and the repository owner requires.
What next
- How a Decision moves: who ratifies, where, and what happens after.
- Token management: classes, roles and how to revoke a token.
- Refusal types: every refusal the server answers with.