From Hobbyist to Production-Grade: Six Things That Actually Separate Them When You Use an AI Coding Agent
· Christian Zanchetta
Job postings in 2026 keep listing “AI tool proficiency” as a requirement without ever defining what it means. In practice, on a real project, it means something very specific — and it’s not “I use an AI coding agent to write code faster.” It’s whether you can run that agent inside a governed system: explicit permissions, enforced verification, auditable guardrails, CI integration. That’s the difference an interviewer is actually screening for, whether the posting says so or not.
I went through this exercise on a real backend I’m building — a multi-tenant AdonisJS 7 + PostgreSQL API with a separate Astro marketing site. Three weeks ago, the AI agent’s entire configuration was one leftover permission rule. No deny list, no guardrails, no CI gate. If I’d asked it to read a .env file with database credentials in it, it would have just… done it. That’s not a confession — that’s where almost everyone starts, and where most people stay.
Here’s what changed, concretely, across six dimensions — with the actual config, not a description of it.
The gap in one table
| Dimension | Hobbyist usage | Production-grade usage |
|---|---|---|
| Permissions | Default prompts, or blanket “skip permissions” | Explicit allow / ask / deny rules, committed and reviewed |
| Project memory | No persistent instructions, or a stale file | A maintained instructions file, reviewed in PRs like code |
| Guardrails | None | Hooks that physically block reading secrets or editing generated files |
| Automation | Interactive chat only | Headless mode wired into CI for review and verification |
| Task decomposition | One long chat thread doing everything | Subagents and reusable skills for isolated, repeatable workflows |
| Verification | “It ran once, ship it” | Tests/lint enforced before a task counts as done |
Memorize the axes, not the row labels — the specific tool names will keep changing, the categories won’t.
1. Permissions: deny by default, not ask by default
The instinct is to configure permissions reactively — approve things as they come up. That’s how you end up with one leftover rule after three weeks of real use. The production version is written deliberately, in three tiers:
{
"permissions": {
"allow": [
"Bash(npm run typecheck)",
"Bash(npm run lint)",
"Bash(npm run test)",
"Bash(node ace migration:run)",
"Bash(node ace make:resource *)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git add*)"
],
"deny": [
"Read(**/.env)",
"Read(**/.env.production)",
"Edit(api/database/schema.ts)",
"Write(api/database/schema.ts)",
"Bash(git push --force*)",
"Edit(.claude/settings.json)",
"Write(.claude/settings.json)"
],
"ask": [
"Bash(git push*)",
"Bash(npm install*)",
"Bash(node ace migration:rollback*)"
]
}
}
Every deny line has a reason, not a vibe:
.env*holds database credentials and API keys. The agent doesn’t need to see them to do its job — more on that below.schema.tsis generated from migrations. Hand-editing it desyncs it from the actual schema; denying edit access turns a documented convention into an enforced one.git push --forceis denied outright, not just gated behind a confirmation — a history rewrite should never happen as a side effect of an agent losing track of context..claude/settings.jsonitself is denied. This is the one people miss: if the agent can edit the file that defines its own permissions, you don’t actually have a permission system — you have a suggestion that the agent can revise on a bad day, in an ambiguous session, without you noticing. Deny is supposed to be absolute. It stops being absolute the moment the constrained party can also edit the constraint.
Scope Bash allows to exact subcommands. Never a bare Bash(git *) or Bash(npm *) — that defeats the entire point of an allowlist.
There’s also a three-level file hierarchy worth knowing precisely: a personal, user-level config that never touches the repo; a project-level file that’s committed and team-owned; and a local override file that’s gitignored, where day-to-day “always allow” clicks accumulate before you periodically promote the useful ones upstream by hand. The committed file is policy. The local one is noise you clean up.
2. Project memory: documentation for the agent, not for humans
A persistent instructions file (CLAUDE.md, or your tool’s equivalent) is read at the start of every session. It’s not a copy of your README — it’s the load-bearing conventions your code doesn’t say for itself:
## Non-negotiable invariants
- Tenant isolation is enforced in the repository layer — never query the
DB from a controller or service directly. A query outside a repository
is a security bug, not a style issue.
- `database/schema.ts` is GENERATED by the migration runner.
Never hand-edit it — regenerate it.
Three weeks in, without this file, the agent had to rediscover these rules by making the mistakes first. With it, the rule exists before the mistake does.
The habit that keeps it from going stale like every README does: at the end of a session where the agent got something wrong because it didn’t know a convention, tell it to add what it just learned to the file. It drafts the addition, you review and commit. Living documentation instead of a document nobody opens again.
One precision worth having ready for an interview: there is no single company-wide version of this file at real organizational scale — that governance layer is a separate, IT-managed settings file, distinct from the project-owned instructions file. Don’t blur the two; it’s a fast way to lose credibility with someone who actually runs one.
3. Guardrails: rules that can’t be talked out of
Permission rules are declarative — fast, but just configuration. A hook is an actual script that runs at a lifecycle event and can block the action outright:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(node -e '
let data = "";
process.stdin.on("data", d => data += d);
process.stdin.on("end", () => {
try {
const j = JSON.parse(data);
process.stdout.write(j.tool_input?.file_path || "");
} catch (e) { process.stdout.write(""); }
});
' <<< "$INPUT")
case "$FILE_PATH" in
*.env|*.env.production)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"policy: .env files hold credentials"}}'
exit 2
;;
*) exit 0 ;;
esac
This duplicates the deny rule from section 1 on purpose. The deny rule is the cheap, static first line. The hook is the auditable, extensible second line — you can later have it log every attempted read of a secret, or check content instead of just a path. Committing the hook script matters as much as committing the permission rule: a settings.json that references a hook script nobody committed is a security control that silently does nothing on anyone else’s machine.
The question this actually answers: “if the agent can’t read .env, doesn’t blocking it break automation that depends on the database?” It doesn’t — and this is the part most people get wrong when they first set this up. Blocking Read on .env stops the agent from seeing the file’s contents in its own context. It does not stop a shell command the agent runs from loading that same file itself. A migration runner or a resource generator invoked via an allowed Bash command reads .env from disk exactly as it always did — the credentials never pass through the agent, because the agent never needed to see them to say “run this migration.” What you lose is the agent constructing an ad-hoc database command by hand using a raw connection string it read itself. For that narrow case, wrap the operation in an allowlisted script that reads the secret as its own subprocess, or keep an .env.example with variable names but no real values so the agent still knows the shape of the config without the values.
4. Automation: headless mode as a CI citizen
The single most interview-relevant skill here: running the agent non-interactively, wired into a pipeline step, with clean exit codes instead of a chat window.
name: Verify
on:
pull_request:
paths: ["api/**"]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm install
- run: npm run typecheck && npm run lint && npm test
Notice this first step has no AI in it at all — and it’s already the bigger maturity jump. Before it existed, the “check everything before a commit” command lived in a README and in your own discipline. Now the pipeline enforces it regardless of who’s committing, human or agent. Only after this baseline exists does it make sense to layer an agent-driven review step on top of it.
5. Task decomposition: subagents and skills
Two mechanisms worth keeping distinct. A subagent is a specialist instance with its own context and a restricted toolset — good for a narrow, repeatable review:
---
name: schema-guardian
description: Reviews changes touching migrations, repositories, or
permissions for tenant-isolation and type-safety violations.
tools: Read, Grep, Glob
---
Check exactly two invariants: every DB query lives in the repository
layer and is tenant-scoped; permission slugs come from the typed
catalogue, never a raw string. Nothing else.
A skill is a packaged, repeatable workflow invoked as a single command — turning a five-step manual process into one instruction that behaves identically every time, for anyone on the team:
---
name: new-resource
description: Scaffolds a new CRUD resource using the project's standard
pattern.
---
1. Run the scaffolding command for <Name>.
2. Fill in the migration columns, then regenerate the schema — never
hand-edit it.
3. Confirm permissions were registered, not hardcoded.
4. Run typecheck, lint, and tests before reporting done.
This is what makes AI-assisted output consistent across a team instead of just fast for one person — a subagent and a skill are both things you can hand to a colleague, not a personal prompting trick that lives only in your head.
6. Verification and visibility
The discipline usually already exists as a command you run before every commit. The production-grade move is making it enforced, not remembered — a hook that runs after every edit and reports failure back into the session, so the agent fixes it before telling you it’s done, instead of you discovering it later:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'cd api && npm run typecheck && npm run lint'",
"timeout": 60
}
]
}
]
}
}
Pair this with basic telemetry — session count, token cost, tool-permission decisions — even just printed to a console locally. It turns “I use AI a lot” into a number you can actually quote.
The part that’s easy to miss
Every guardrail above assumes a trust boundary: the agent operates inside rules a human set, and cannot widen those rules itself. That boundary is only real if the file defining the rules is treated with the same suspicion as the actions it restricts. It’s a natural blind spot — you write the deny list to stop the agent from touching secrets, and forget the deny list is itself just a file the agent can edit unless you say otherwise. Close that loop before you call the setup production-grade.
None of this required a rewrite. It’s about two weeks of deliberate work layered on a project that was already being built — and at the end of it, “AI-assisted development” stops being a vague line on a CV and becomes an actual permission model, an actual guardrail, an actual CI gate, with a real repository behind every claim.