How to Give AI Agency Without Giving It Reckless Power
Building Alfred, Part 4 of 6. Parts 1 to 3 covered architecture, grounding and memory. This one is about letting an agent do things.
Alfred investigates an incident, finds the likely defect, and identifies the exact code path involved.
At that moment the interesting question is no longer whether the AI is intelligent. It is whether the AI should be allowed to do anything about what it found.
Send the customer email? Change a setting? Edit the repository? Merge the fix?
Agent discussions tend to split into two unhelpful camps. One builds assistants that explain everything and change nothing. The other treats removing human control as the goal.
We chose a third thing: give the agent enough power to move work forward, and put the strongest boundary exactly where impact becomes hard to reverse.
The ladder
Reading a call record is not the same as changing it. Drafting an email is not sending it. Preparing a change in an isolated branch is not merging it.
Six rungs, and Alfred implements all of them:
- Observe. Retrieve narrow, permission-scoped evidence. Read only wherever possible.
- Recommend. Explain findings, state uncertainty, suggest a step. The human acts.
- Prepare. Produce a complete draft, plan or isolated change. Reviewable, not final.
- Validate. Run organisation-configured checks and report the result honestly.
- Approve. A human authorises at the point of impact.
- Review. The outcome stays visible, attributable and reversible where possible.
The value of the ladder is that capability can grow without collapsing every decision into one permission called “agent access.”
Permissions have to exist outside the prompt
A prompt can tell a model not to do something. That is useful. It is not a boundary.
Alfred has three roles and ten capabilities in one dict. Exactly one capability currently gates tools:
"manage_repository_code": frozenset({"admin", "tech"}),
and it covers eleven tools:
TOOL_CAPABILITY_REQUIREMENTS: dict[str, str] = {}
def _register_repository_write_tools() -> None:
for name in (
START_CODING_WORKSPACE_TOOL_NAME, READ_CODING_WORKSPACE_FILE_TOOL_NAME,
EDIT_CODING_WORKSPACE_TOOL_NAME, INSPECT_CODING_WORKSPACE_TOOL_NAME,
VALIDATE_CODING_WORKSPACE_TOOL_NAME, WAIT_CODING_WORKSPACE_CHECKS_TOOL_NAME,
READ_CODING_WORKSPACE_CI_LOGS_TOOL_NAME, PUBLISH_CODING_WORKSPACE_TOOL_NAME,
CREATE_PULL_REQUEST_TOOL_NAME, UPDATE_PULL_REQUEST_TOOL_NAME,
CLOSE_PULL_REQUEST_TOOL_NAME,
):
TOOL_CAPABILITY_REQUIREMENTS[name] = REPOSITORY_WRITE_CAPABILITY
Two separate mechanisms enforce it, and the distinction matters.
Advertisement is per request. ToolRegistry.schemas() decides what the model is even told exists:
if self.code_tool is not None:
repo_names = self.code_tool.repo_names()
schemas.extend(code_tool_schemas(repo_names))
if has_capability(user_roles, REPOSITORY_WRITE_CAPABILITY) or approval_enabled:
schemas.extend(coding_workspace_schemas(repo_names))
schemas.append(create_pull_request_schema(repo_names))
Repository enums come from live GitHub discovery. The Gmail account enum contains only the caller’s linked addresses. Inside a scheduled run, the repeat-admin tools are withheld entirely so, in the words of the comment, “a scheduled task cannot silently breed more scheduled tasks.”
Execution re-checks at dispatch, per tool call:
if not context.has_capability(REPOSITORY_WRITE_CAPABILITY):
return ToolExecutionResult(
content="Only users with the admin or tech role can write code or create PRs.",
is_error=True,
)
Not advertising a tool is a UX optimisation. Refusing it at dispatch is the boundary. If those two ever disagree, the dispatch check wins, which is the correct direction for them to fail.
Four human-in-the-loop mechanisms
ask_user pauses the turn
A well-formed ask_user called on its own ends the turn without emitting a tool_result. The question is persisted. When the user answers, the answer is inserted as the tool result and the same turn resumes.
That is more subtle than it sounds. The alternative, treating the answer as a new user message, loses the model’s in-progress reasoning and forces it to rediscover where it was. Calling ask_user alongside other tools, or malformed, gets a specific misuse result rather than a generic error, because the model needs to learn the protocol, not just that it failed.
The coding plan gate
This is the strongest control in the system. Before any repository mutation, the model must call request_coding_plan_approval, alone, and wait.
def coding_plan_gate_error(self, action: str) -> str | None:
if not self.coding_plan_required or self.coding_plan_approved:
return None
return (
f"An approved coding plan is required before {action}. Investigate the scoped "
"repositories, call request_coding_plan_approval on its own, and wait for the user "
"to approve it."
)
Two details make it hold.
First, the requirement is derived from capability, not from how the request was phrased:
# The heuristic improves routing, but safety cannot depend on phrasing.
# Any repository mutation attempt must be grounded and plan-approved.
coding_preflight_required=repository_write_capable,
There is a phrasing heuristic, and it improves model routing. It is explicitly not load bearing. A user who says “just quickly patch this” does not get a different safety posture.
Second, approval requires grounding, not just consent. coding_grounding_error demands that the model has called understand_repository plus a search or read in every scoped repository before it is allowed to propose a plan. When a task names three repositories, it cannot read the most convenient one and guess at the contracts in the others. Repository access stops being a search feature and becomes an evidence requirement.
Approval state is reconstructed from persisted history on every turn, and any fresh natural-language user turn resets the approved scope. You cannot get a plan approved for one thing and then reuse the approval for another.
Server-side confirmed backstops
Prompt-level confirmation is not enough for anything that crosses the company boundary. Gmail send:
if not context.repeat_run_id and tool_input.get("confirmed") is not True:
return ToolExecutionResult(content=GMAIL_SEND_UNCONFIRMED_MESSAGE, is_error=True)
confirmed is also a required schema field outside scheduled runs, so the model cannot omit it and hope. Repeat task create, update and delete carry the same flag.
The general principle: the user should see the object that will cross the boundary before it crosses. An ambiguous “go ahead” three messages earlier should not become an external message.
Discord approvals turn a refusal into a request
On Discord, a capability the user lacks does not produce a dead end. It produces an approval request posted to admins with approve and deny buttons.
The implementation details are where the value is. The approval row survives a process restart. The requester is excluded from the admin list, so nobody approves their own request. With no eligible admins the request is marked expired rather than hanging. The decision is a single-winner compare-and-swap, so two admins clicking simultaneously cannot both grant. On approval the original request is re-run once, with exactly that one capability granted. And the model receives a stop-do-not-retry result, so it does not spend the intervening iterations trying alternate routes.
What “dangerous” means here
There is no data-driven risk taxonomy. Danger is expressed three ways: the capability requirement set, the confirmed-gated set, and removal of the primitive entirely. There is no merge tool. There is no write SQL. You cannot gate what does not exist.
The coding workspace
Once a plan is approved, implementation moves into a per-conversation git worktree on a generated branch.
Edits are exact-match, single-occurrence, atomic batches. Path traversal is rejected, repository metadata is off limits, file count and byte size are bounded, and folder deletion cannot target the repository root. Any edit resets validation_passed, so a change cannot inherit a previous run’s green check.
Validation is the part I would point at if someone asked what taking this seriously looks like. The model does not get a shell:
# The model cannot provide shell commands.
Trusted administrators configure argv arrays. The environment is scrubbed down to PATH, HOME, TMPDIR, LANG, LC_ALL plus CI=true, so application secrets are not present. Output is capped, and the GitHub token is replaced with *** in both git stderr and validation output, because the token appears in the on-disk remote URL and must never reach a prompt or a tool result.
Then the check that I think is the cleverest thing in the file: if validation itself modified tracked files or moved HEAD, the result is invalid. A test suite that quietly rewrites a lockfile does not get to certify its own success.
Local validation execution is disabled by default, with an honest reason in the config comment: “argv allowlisting is not an OS/container sandbox.” In the default deployment there is no local test execution at all, and the real execution boundary is a draft pull request plus GitHub Actions. I would rather ship a feature flag defaulted off with a truthful comment than claim a sandbox we did not build.
The pull request is the product boundary
Publishing produces a draft PR. The constraints:
- Branch targets are always
batman-agent/*. Pushes are never forced. - There is no merge primitive. The comment in the source states it directly: “There is deliberately no primitive that writes without opening a PR and no merge primitive.”
- Fork PRs are rejected.
update_pull_requestrequires the PR to be open, in the same repository, with a head matching the agent prefix. - Checks are evaluated against the exact published commit SHA, not a convenient earlier run.
- Failed job logs can be read, a repair prepared, and the new head validated again.
- Commits are authored as
DialNexa Batman Agent <[email protected]>, and the PR body says “Created by the Batman agent for {requested_by}.” - Closing a PR posts the reason as a comment. Merged PRs cannot be closed by the agent. The branch is deleted only if it carries the agent prefix and belongs to the same repository.
One caveat the code states about itself: “a human could name a branch batman-agent/* – but the blast radius stays confined to that namespace.” The prefix is a confinement mechanism, not proof of authorship. Do not build an audit conclusion on it.
This workflow does not make the agent less agentic. It lets the agent complete far more of the expensive work while preserving an accountability surface engineers already know how to use.
Blast radius is mostly a budget problem
The control that does the most work in practice is money.
BudgetService enforces a per-user monthly USD budget, default $25, with an optional daily cap. Three details:
# Advisory-locked read so two concurrent starts can't both slip under the cap.
Without that, three simultaneous requests all read the same remaining balance and all decide they can afford it.
# Unknown model IDs bill at the most expensive configured rate.
A new model appearing in the wild must never be free. Cache writes bill at 1.25x, cache reads at 0.1x, web search at a flat $0.01 per request.
And inside a scheduled run, the budget is enforced before each model call rather than after. messages.count_tokens runs, affordable_output_tokens shrinks max_tokens so one call cannot breach the cap, and if real billing beats the estimate, unexecuted tool calls receive:
Not executed because the Repeat run budget was exceeded.
An explicit no-side-effect-after-the-breach path. The run stops without half-completing something.
Other bounds: three concurrent runs per user, thirty iterations per message, a 900 second session lock TTL, list_due_tasks(limit=50), and a 50,000 event buffer safety valve.
The nine-line allowlist and three-line denylist on the internal API proxy, the twenty-four keyword SQL denylist, the batman-agent/ prefix, the GitHub org scope, and the per-provider connector allowlists from Part 2 all serve the same purpose: shrink what a mistake can reach.
Reversibility as a design axis
I now evaluate agent actions largely by how recoverable they are.
A draft can be edited. An isolated branch can be deleted. A proposed memory can be rejected. A browser connection can be revoked. A scheduled task can be disabled.
A sent email cannot be unsent. A merged production change is expensive to undo. Money spent through a connector is gone.
The closer an action sits to the irreversible end, the more explicit the approval and the evidence should be. That is why gmail_send_email has a server-side flag and gmail_search does not, and why the Outrank purchase endpoint is blocked outright rather than gated.
What is missing, plainly
The gap list matters more than the feature list, because anyone evaluating this should know where it is thin.
The capability model is effectively one bit. One capability, eleven tools. Sending email as the user, scheduling autonomous spend, overwriting a team-shared skill, generating client-facing PDFs and querying production Postgres all require nothing beyond having Alfred enabled. Those are gated by presence (is a Gmail account linked, is a connector configured) rather than by role.
Two enforcement mechanisms can drift. required_capability() is consulted only in the Discord approval branch. Hard enforcement is hand-written has_capability checks inside dispatch. A new gated tool added to the dict but not given a dispatch check would be gated on Discord and unenforced everywhere else. That should be one mechanism.
No dry-run mode anywhere. The nearest analogue is inspecting a diff before publishing.
Idempotency is nearly absent. It exists for managed connector writes, model supplied and optional. Gmail send, PR creation and workspace publish have none. The interrupted-tool repair text tells the model “Call the tool again if you still need its result,” which for a send or a push is a duplicate-side-effect path.
No rate limiting on tool calls or Alfred routes. The limits are dollars, thirty iterations, and three parallel runs. The parallel-run cap is explicitly soft: “a race between simultaneous sends can briefly overshoot by one; the cap is a guardrail against runaway spend, not a hard scheduler.”
Cancellation probably does not stop in-flight tools. Cancel cancels the driving task. Tool tasks are independent and awaited plainly, so a cancel during a git push raises in the loop without cancelling the push.
Discord approvals never time out. There is no sweep for pending rows. An approval clicked next week will re-run the original request text.
escalate_to_opus is self-service. Any user can move a session to the more expensive model. The only backstop is the budget.
No structured audit table. Auditability comes from the chat transcript, which is genuinely rich: exact tool sequence, complete inputs, complete results, error flags, per-call cost, branch and PR for repository writes. What it lacks is the roles held at execution time, which have to be inferred from current user records. For an access-control incident, that is the wrong direction to be inferring in.
No anomaly detection, circuit breaker or global kill switch. Turning Alfred off means flipping a per-user flag, removing the API key, or restarting the process.
The real goal is trusted momentum
Nobody wants to approve every log read. Nobody wants an agent making consequential external changes in the background.
The design problem is to remove friction from reversible work and concentrate human attention at the point of meaningful impact. Get that split right and people stop thinking about the agent’s permissions at all, which is the actual sign that it worked.
Next, in Part 5: what keeps this running when browsers disconnect, processes restart and tools fail halfway through.
Where would you place the human approval boundary in your highest-value AI workflow?
Previous
Next in the series
Alfred runs on DialNexa, our voice AI platform for businesses. If you are building internal agents and want to compare notes, the comments are open.

Leave a Reply