← Blog
Tutorials9 min readThe Utter team7 viewsUpdated

How to give an AI agent human-in-the-loop approval before it changes your issues

XLinkedIn

The fastest way to lose trust in an AI agent is to let it silently change things. It re-prioritizes a ticket at 2am, closes a bug it thinks is a duplicate, moves a card to Done because a comment sounded resolved. Each move might be right. But nobody saw it happen, and now you are auditing history to figure out why the board looks wrong.

The fix is not a smarter agent. It is a gate. The agent should propose, and a person should approve before anything real changes. That is what "human in the loop" means in practice, and you can build a solid version of it in Utter today out of three parts you already have: custom board statuses, workflow automation on issue events, and scoped API keys.

One honest note up front. Utter does not have a dedicated "agent approval" screen with a queue of pending actions and an Approve button. What it has are the pieces you compose into that gate. This post is the recipe, including where the edges are.

The idea: the board is the approval queue

You do not need a separate approval inbox if your board already has a column that means "waiting for a human." So make one.

The Utter board with a dedicated In Review column, where an agent parks work for a human to approve

On any Utter board, statuses are your own columns, not a fixed set. Each column maps to a category (backlog, todo, in progress, in review, done, and a couple of terminal ones) that drives reports and the open/closed flag, but the name and color are yours. Add a column called Needs review. That column is the approval queue. Anything sitting in it is something the agent did that a person has not yet signed off on.

The whole gate comes down to one rule: the agent can push work into Needs review, and only a human can push it out.

The gate, in one picture:

flowchart TD
    A["Agent prepares the work"] -->|"the agent's last move"| NR["Needs review"]
    NR -->|"human: fine, do it"| IP["In progress"]
    NR -->|"human: not now"| B[Backlog]
    NR -->|"human: wrong call"| RJ["Rejected / Won't do"]

Step one: let the agent land in Needs review, not in your live flow

Point your agent at the REST API or the MCP server and give it a job with a clear finish line. Triage is the classic one. A bug comes in, the agent reads it, sets a type and priority, maybe drafts a repro, and files it.

The one difference from a fully autonomous setup: the agent's last move is always into Needs review. It does not set the ticket to In progress or assign it to a sprint. It stops at the gate.

There are two ways to make that happen, and using both is fine.

The direct way is to tell the agent, in its instructions, to create or move the issue into the Needs review status and go no further. On the wire that is two calls. First, look up the id of your Needs review column:

curl https://utter.ae/api/v1/workspaces/utter/projects/WEB/statuses \
  -H "Authorization: Bearer utp_live_a1b2..."

The response lists every column on the board, in order, with its workflow settings:

{
  "data": [
    {
      "id": "0197a3c2-8f1e-7c3a-b2d4-5e6f7a8b9c0d",
      "name": "Needs review",
      "category": "in_review",
      "allowed_transitions": null,
      "wip_limit": null
    }
  ]
}

Then file the issue with that status_id, so it lands in the gate and nowhere else. Here is the same create in curl, JavaScript, and Python:

curl -X POST https://utter.ae/api/v1/workspaces/utter/projects/WEB/issues \
  -H "Authorization: Bearer utp_live_a1b2..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "bug",
    "title": "Checkout 500 when the cart has a deleted SKU",
    "description_md": "Steps to reproduce: ...",
    "priority": "high",
    "status_id": "0197a3c2-8f1e-7c3a-b2d4-5e6f7a8b9c0d"
  }'
const res = await fetch(
  "https://utter.ae/api/v1/workspaces/utter/projects/WEB/issues",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UTTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "bug",
      title: "Checkout 500 when the cart has a deleted SKU",
      description_md: "Steps to reproduce: ...",
      priority: "high",
      status_id: needsReviewId,
    }),
  },
);
const { data: issue } = await res.json(); // 201, issue.key is e.g. WEB-142
import os
import requests

r = requests.post(
    "https://utter.ae/api/v1/workspaces/utter/projects/WEB/issues",
    headers={"Authorization": f"Bearer {os.environ['UTTER_API_KEY']}"},
    json={
        "type": "bug",
        "title": "Checkout 500 when the cart has a deleted SKU",
        "description_md": "Steps to reproduce: ...",
        "priority": "high",
        "status_id": needs_review_id,
    },
)
r.raise_for_status()
issue = r.json()["data"]

Simple, but it relies on the agent following instructions, which is exactly the thing you are trying not to trust blindly.

The reliable way is an automation rule that enforces it regardless of what the agent intended. Utter automations follow a plain shape: WHEN a trigger fires, IF some conditions match, THEN run some actions. The triggers that matter here are issue.created and issue.status_changed. The landing rule looks like this:

  • WHEN an issue is created
  • IF the reporter is the agent (match on assignee, label, or a naming convention you control)
  • THEN set its status to Needs review

The automation recipe gallery, including a rule that routes new bugs on creation

Now even if the agent tries to file straight into In progress, the rule pulls it back to the gate. The agent proposes; the board decides where a proposal is allowed to sit.

You can also use the automation to do the paperwork a human would otherwise chase: add a "needs-review" label, drop a comment with a template that says what the agent changed, assign it to whoever owns triage that week. Automation comment bodies support {issue}, {assignee}, and {actor} placeholders, so the note can name the agent that acted.

Step two: approve by moving the card

This is the part that stays human, and it should feel boring. A person opens Needs review, reads the ticket the agent prepared, and drags it to the next real column. To do the work: fine, In progress. Not now: Backlog. Wrong call: your Rejected or Won't do column.

That drag is the approval. There is no separate button because there does not need to be one. The move is recorded in the issue's activity log with the person's name on it, which is the audit trail you wanted in the first place. If someone asks "who approved this," the history answers.

Because the approval is a normal board action, everything else you already use keeps working: saved views filtered to Needs review, the assignee picker, comments, mentions. You are not learning a new surface. You are using the board as the review desk.

Step three: stop the agent from skipping the gate

Instructions and a landing rule get you most of the way. Transition rules close the last gap.

Workflow transition rules in project settings: each column has a Can move to setting, so a status cannot be skipped

Utter supports Jira-style status transitions. By default any column can move to any other, which is the sensible default for a small team. But each status can carry an allowed_transitions list (that is the null you saw in the statuses response above, meaning "allow all"): the set of columns work is allowed to move into from there. Set it, and a move that is not on the list gets refused.

Watch it happen. The agent tries to jump its ticket straight to Done:

curl -X POST https://utter.ae/api/v1/workspaces/utter/projects/WEB/issues/WEB-142/transition \
  -H "Authorization: Bearer utp_live_a1b2..." \
  -H "Content-Type: application/json" \
  -d '{"status": "done"}'

And the API answers with a 400, not a moved card:

{
  "error": {
    "code": "validation",
    "message": "Workflow blocks this transition: \"Needs review\" cannot move to \"Done\".",
    "details": {
      "code": "workflow_transition_blocked",
      "from": "Needs review",
      "to": "Done"
    }
  }
}

Here is the important detail: transition rules are enforced on every status write path, including the public API and MCP. They are not a UI nicety that an agent routes around by calling an endpoint. So if you make the terminal columns (In progress, Done) reachable only from Needs review, and you do not put the agent's landing column on a path that jumps the gate, the agent physically cannot move a ticket past review. The write is refused, the same as it would be for a person.

sequenceDiagram
    participant Agent
    participant API as Utter API
    participant Human
    Agent->>API: create issue in Needs review
    API-->>Agent: 201 created
    Agent->>API: transition to Done
    API-->>Agent: 400 workflow blocks this transition
    Human->>API: drag card to In progress
    API-->>Human: move recorded in activity

Pair that with per-column WIP limits if you want a hard cap on how much the agent can stack up waiting for a human.

Be clear-eyed about what this does and does not buy you. Transition rules control which column-to-column moves are legal for everyone. They do not, on their own, say "agents follow rule A and humans follow rule B." If a human also has to move cards the same way, the constraint applies to them too. In most review workflows that is fine, even good, because the gate is the point. But it means the enforcement is workflow-shaped, not identity-shaped.

Step four: scope the key so the blast radius is small

The last layer is the API key. Never hand an agent a coarse key.

The developer keys tab, where each API key is minted with explicit scopes

Utter keys are scoped, and scopes only narrow, never widen past the role of the person who minted the key. For a triage-and-propose agent, issues:write plus issues:read is usually enough: it can create issues, edit fields, and move them between columns, but it cannot touch project settings, manage members, or delete a project.

It also cannot edit the automations or the transition rules that define the gate, because those sit behind automations:write and project settings, which are admin-tier scopes deliberately kept out of the plain write grant. So the agent cannot quietly remove the fence it is standing behind.

Mint a separate key per agent instead of sharing a login. Every change is then attributed to that agent, and if it misbehaves you revoke one key without disrupting anyone else. Utter also watches for anomalous key usage and can revoke on a spike, which is a backstop, not a substitute for scoping it tight.

Where the seams are

This is composed, not bought, so it has seams worth naming.

There is no single "pending approvals" dashboard beyond a saved view of the Needs review column. That view is genuinely enough for most teams, but it is a filtered board, not a purpose-built queue with bulk approve.

Transition rules are per-project. If your agent works across ten projects, you configure the gate in each. Automations are workspace-level and can filter by project, so the landing rule scales better than the transition rules do.

And the enforcement is on the board, not on the intent of the actor. The gate stops a bad move; it does not read the agent's mind. That is a feature if you treat the board as the source of truth, which you should.

Why compose it this way

You could wait for a vendor to ship an "AI approval center." Or you can notice that an approval workflow is just a review column, a rule that routes proposals into it, a rule that stops anything from jumping past it, and a key that cannot dismantle the setup. Those already exist, they are enforced on the same paths your agent calls, and they leave a clean audit trail because approval is a normal human action on the board.

The result is the split that keeps working after the demo: the agent does the repetitive, checkable 80 percent and lands it at the gate. A person spends thirty seconds moving a card and owns the decision that carried risk.

If you want to build this, start with one column and one automation rule on issue.created, point a scoped key at a single project, and watch the Needs review lane for a week before you widen anything. See the API reference for the endpoints, or read running a project with AI agents for the wider setup this fits into.

Frequently asked questions

How do I add human approval before an AI agent changes my issues?

Build a gate from three pieces Utter already has: a custom "Needs review" board column the agent always lands in, an automation rule on issue.created that routes agent-filed issues into that column, and transition rules that stop anything from moving past it. The agent proposes; a person approves by dragging the card to the next column.

Does Utter have a dedicated agent approval screen?

No. There is no purpose-built queue with a bulk Approve button; the approval queue is a "Needs review" board column plus a saved view filtered to it. The approval itself is a normal board move, recorded in the issue's activity log with the person's name, which is the audit trail you wanted in the first place.

How do I stop an AI agent from skipping the review gate?

With transition rules. Each status can carry an allowedTransitions list, and a move not on the list is rejected on every status write path, including the public API and MCP, so an agent cannot route around the gate by calling an endpoint. Make In progress and Done reachable only from Needs review, and the write is refused the same as it would be for a person.

What API scopes should an AI agent's key have?

For a triage-and-propose agent, issues:read plus issues:write is usually enough: it can create, edit, and move issues, but it cannot touch project settings, manage members, or edit the automations and transition rules that define the gate. Mint a separate key per agent so every change is attributed and one key can be revoked without disrupting the rest; running a project with AI agents covers the wider setup.

Related reading

أضف تعليقًا

ابدأ النقاش.