In Part 1, I built the fence. sp_Blitz writes to DBA.dbo.BlitzResults, BlitzReader can SELECT from that one table and nothing else, and Claude Code wrote a report that compares the last two runs. But I ran everything myself. Every sp_Blitz run, and every report.
Part 2 is going to take me out of the loop. sp_Blitz runs on a schedule, and Claude Code runs the report on a schedule, as BlitzReader, without me. And nothing runs as sysadmin that doesn't have to.
| Component | Details |
|---|---|
| SQL Server | 2025 CU7, Enterprise Developer, local dev instance |
| sqlcmd | ODBC, 17.0.1000.7 |
| AI agent | Claude Code, desktop app |
| Project folder | C:\BlitzApp |
Step 1: Update the Ground Rules
Part 1's CLAUDE.md said not to connect to any database. That changes today. It can connect one way, as one login. It can also write scripts that change the server, but it never runs them. I do:
# Ground rules - Work only in this project folder. - Connect to SQL Server only with sqlcmd, as BlitzReader: sqlcmd -S sqlfingersinc -U BlitzReader -d DBA The password comes from the SQLCMDPASSWORD environment variable. Never put it on the command line. - All SQL you run is SELECT-only, against DBA.dbo.BlitzResults. - Every query has a TOP clause. - You may write scripts that change the server. You never run them. I do. - Ask before running any command. - Log every attempt in attempts.md: what you tried, what worked, what didn't.
Don't forget what we learned in Part 1. CLAUDE.md is guidance. SQL Server is the fence.
Step 2: Give It a Connection
The password doesn't go on the command line. Microsoft says -P is insecure and recommends the SQLCMDPASSWORD environment variable instead (sqlcmd utility).
Claude Code can set environment variables for its sessions with an env block in its settings. I put it in .claude/settings.local.json, which applies only to me, in this project (Claude Code settings):
{
"env": {
"SQLCMDPASSWORD": "[USE A STRONG PASSWORD]"
}
}
To be clear, the agent can read that file. The password is still stored in plain text, just not exposed on the command line. That's acceptable for this local experiment, but it's not how I'd manage credentials in production. SQL Server restricts BlitzReader to SELECT on one table. That's the fence.
First connection test, in a new session with the folder set to BlitzApp and the mode set to Manual:
Read CLAUDE.md. Connect to SQL Server with sqlcmd as BlitzReader and run this query, then show me the result: SELECT TOP (5) CheckDate, COUNT(*) AS Findings FROM DBA.dbo.BlitzResults GROUP BY CheckDate ORDER BY CheckDate DESC;
It didn't connect:
ODBC Driver 18 encrypts by default and validates the server's certificate, and a fresh local install uses a self-signed one. The agent explained that, logged the attempt, offered two fixes, and asked before trying either. I chose -C, which tells sqlcmd to trust the server certificate without validating it (sqlcmd utility). The connection is still encrypted. For a local dev instance, that's a reasonable trade.
Look at what's not on that command line: a password. sqlcmd picked it up from SQLCMDPASSWORD, exactly as planned.
It worked. The agent logged in as BlitzReader, read the table, and returned the results. It asked for permission before writing what it had done to attempts.md. I also added the -C into the sqlcmd line in CLAUDE.md, so the agent will connect the same way from here on.
Step 3: A Least-Privilege Job Owner
A scheduled job needs an owner, and that owner shouldn't be me, because I'm sysadmin. Any T-SQL job step owned by a non-sysadmin runs as that owner (sp_add_jobstep), so the owner needs exactly what sp_Blitz needs -- and nothing more.
Brent's original documentation says sp_Blitz needs sysadmin, but I decided to grant BlitzRunner only the minimum perms needed to get the job done. I gave EXECUTE on sp_Blitz and INSERT on BlitzResults. I figured I'd run sp_Blitz twice, first as myself and then as BlitzRunner, and compare the output. But...
A hard stop. sp_Blitz wouldn't run at all, and nothing was written to the table. So I granted VIEW SERVER STATE and tried again. This time it ran, but only partially:
So, I can see right out the gate that my initial permissions weren't enough. From there, sp_Blitz's own output told me what to grant next. I enabled one permission at a time, testing after each change. Eventually, BlitzRunner returned the same findings as my sysadmin run, with one exception I'll get to below. Three things I learned along the way:
- VIEW SERVER STATE is the only hard requirement. Without it, sp_Blitz stops before writing anything. With it, a non-sysadmin run works to some extent, and sp_Blitz adds a 'Some Checks Skipped' row to say it couldn't run everything.
- BlitzRunner needs SELECT on the database where sp_Blitz is installed -- DBA, in my case. sp_Blitz builds its list of databases it may check using fn_my_permissions, passing each database's name. Any database missing from that list is skipped. Microsoft's documentation says the securable should be NULL for a database (fn_my_permissions). In my tests, the answer depended on the database sp_Blitz ran from. SELECT in msdb alone changed nothing. SELECT in DBA, where sp_Blitz is installed, brought every database back to the list, and the backup, Page Verification and Agent findings came back as well.
- The version check calls the rest of the kit. sp_Blitz checks the versions of the other First Responder Kit procedures by executing them. Without EXECUTE on each one, it raises a permission error and records a 'Version Check Failed' finding.
The final sp_Blitz permission set, one script:
USE master;
GO
CREATE LOGIN BlitzRunner
WITH PASSWORD = '[USE A STRONG PASSWORD]',
CHECK_POLICY = ON;
CREATE USER BlitzRunner FOR LOGIN BlitzRunner;
GRANT VIEW SERVER STATE TO BlitzRunner;
GRANT VIEW ANY DEFINITION TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_MSgetalertinfo TO BlitzRunner;
GO
USE msdb;
GO
CREATE USER BlitzRunner FOR LOGIN BlitzRunner;
GRANT SELECT TO BlitzRunner;
GO
USE DBA;
GO
CREATE USER BlitzRunner FOR LOGIN BlitzRunner;
GRANT SELECT TO BlitzRunner;
GRANT INSERT ON dbo.BlitzResults TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_Blitz TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzAnalysis TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzBackups TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzCache TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzFirst TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzIndex TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzLock TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_BlitzWho TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_DatabaseRestore TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_ineachdb TO BlitzRunner;
GRANT EXECUTE ON dbo.sp_kill TO BlitzRunner;
GO
Everything there is read access or permission to call a procedure. No writes outside of BlitzResults, but EXECUTE deserves a closer look. Depending on how a procedure is written and its execution context, it may perform operations the caller couldn't execute directly. I granted these permissions to get the First Responder Kit working under BlitzRunner, but that doesn't make every procedure harmless.
The proof. My sysadmin run against BlitzRunner's final run, compared on CheckID, DatabaseName and Finding:
Seen CheckID DatabaseName Finding Baseline only 156 NULL Oct 22 2026 6:06PM BlitzRunner only 156 NULL Oct 22 2026 8:03PM BlitzRunner only 223 NULL Some Checks Skipped
CheckID 156 is the run date, so it never matches between runs. The other findings matched, but CheckID 223, 'Some Checks Skipped,' remained. That's important. The matching findings are encouraging, but I can't claim identical coverage until I know exactly what was skipped.
Step 4: Schedule sp_Blitz
In Part 1, I ran sp_Blitz by hand. This time I want it running on a schedule, and the job will be owned by BlitzRunner, the login we configured above in Step 3. It has substantially fewer privileges than sysadmin, but I will say that I haven't established that every permission granted is strictly necessary.
My CLAUDE.md lets the agent write the scripts, but not run them. And even if it tried, it would fail, because its only connection is BlitzReader -- who has only SELECT on BlitzResults. This is my request to Claude Code:
What it wrote:
EXEC msdb.dbo.sp_add_job
@job_name = N'DBA - sp_Blitz to BlitzResults',
@owner_login_name = N'BlitzRunner';
EXEC msdb.dbo.sp_add_jobstep
@job_name = N'DBA - sp_Blitz to BlitzResults',
@step_name = N'Run sp_Blitz',
@command = N'EXEC DBA.dbo.sp_Blitz
@CheckUserDatabaseObjects = 0,
@OutputDatabaseName = ''DBA'',
@OutputSchemaName = ''dbo'',
@OutputTableName = ''BlitzResults'';';
-- schedule: every 30 minutes
That's trimmed to the parts that matter. The full script also stops without making changes if BlitzRunner doesn't exist or the job is already there, and it does everything in one transaction so a failure leaves nothing behind. I didn't change any thing. I ran it in SSMS, started the job once by hand, and checked the job history:
'Executed as user: BlitzRunner.' The job runs under the dedicated login, not my sysadmin account.
Step 5: Schedule the Report
Half of the job is done. sp_Blitz runs every 30 minutes and writes its findings to BlitzResults, and I'm not involved at all. The last half is the report, but right now I still have to run report.sql myself.
I want Claude Code to do that as well. Once a day, it should connect as BlitzReader, run the report, save the output, and log what it did, all without me sitting there watching it or having to give permission. Claude Code's desktop app can do this with a scheduled task (Claude Code: Desktop scheduled tasks). It runs right here on my machine, where everything the agent needs already lives: CLAUDE.md, report.sql, and sqlcmd.
This is what I asked for, in the same BlitzApp session:
Create a scheduled task for this project that runs every hour. Each run should read CLAUDE.md, run report.sql with sqlcmd as BlitzReader using the connect line in CLAUDE.md, save the output to a new file in a reports folder named report_YYYYMMDD_HHMM.txt, and log the run in attempts.md. It should not run anything else.
I initially asked for hourly runs, but that didn't make much sense. report.sql compares the last two sp_Blitz runs, and those are only 30 minutes apart. So I changed the schedule to once a day at 5:00PM. Before scheduling anything, Claude read report.sql and found it had changed since it wrote it:
The scheduled task already connects as BlitzReader, so those lines weren't needed. I let it remove them, and report.sql is SELECT-only again. Then it showed me the task it planned to create:
Read the second paragraph of the task instructions. It says I approved those commands for unattended runs, but I didn't. The agent wrote that about me into its own instructions, and every run would have started out believing it. I had that paragraph removed.
With that paragraph gone, the task follows CLAUDE.md and asks before running any command. The first scheduled run did exactly that. It started at 5:07 PM, stopped 13 seconds later, and waited nearly four hours for me. As-is, every command waits for permission to run, and that must be done every time.
Not quite the hands-off I was going for, so I added allow rules to my user settings file, C:\Users\rebec\.claude\settings.json. Claude Code applies those to scheduled task runs (Claude Code: Desktop scheduled tasks), so a command that matches a rule runs without asking. I wrote one for each thing the task does: get the timestamp, run the report, and log the run. The sqlcmd rule is listed twice, once for PowerShell and once for Bash, so it's covered either way:
{
"permissions": {
"allow": [
"PowerShell(Get-Date -Format yyyyMMdd_HHmm)",
"PowerShell(sqlcmd -S sqlfingersinc -U BlitzReader -d DBA -C -W -i report.sql -o reports\\report_*)",
"Bash(sqlcmd -S sqlfingersinc -U BlitzReader -d DBA -C -W -i report.sql -o reports\\report_*)",
"Edit(//c/BlitzApp/attempts.md)"
]
}
}
Each rule targets a specific command or file. The * allows the report's file name to change with each run, but it's a wildcard, not a timestamp validator. These rules control when Claude asks for approval; they don't enforce what SQL Server will allow. I also had Claude rewrite the task to run each command on its own, because a chained command runs without asking only if every part of it matches a rule (Claude Code permissions). This is the one place I loosened the agent's permissions, but the fence didn't move. The command still connects as BlitzReader.
Step 6: Hands Off
With the rules in place, I clicked Run now on the task and kept my hands off. Nothing asked for permission, and nothing waited on me. It checked report.sql, got the timestamp, ran the report as BlitzReader, saved the output, logged what it did, and completed the task with this output:
Look at the first item under 'Things to look at' in that screenshot. The run didn't start late. I started it at 19:29 with Run now. The agent didn't know that, so it guessed, and reported the guess as something for me to look at.
The report ran. The output was saved. The log was written. All of that worked, but the agent also gave me an explanation for something it didn't know, and presented it as a finding. I think it's important to see that successful execution doesn't necessarily mean accurate reporting.
Limits
- One instance. Everything here is from my local dev instance. BlitzRunner returned the same findings as my sysadmin login, but it also reported that some checks were skipped. I haven't established identical coverage, and the permissions required may differ on another server.
- The app has to be open. Claude Code's scheduled tasks run only while the desktop app is running and the computer is awake (Claude Code: Desktop scheduled tasks). The sp_Blitz job doesn't care. The report does.
- An allow rule matches text, not intent. Claude Code says its command rules aren't a security boundary around a program (Claude Code permissions). Mine are narrow, but the real fence is still BlitzReader.
- The table grows. At 37 rows a run, every 30 minutes, BlitzResults gains 1,776 rows a day. There's no cleanup yet.
- The agent's log isn't proof. attempts.md is what the agent says it did, and twice now it has guessed wrong about its own runtimes.
What's Next
In Part 3, I'll turn this into something I'd actually run. sp_Blitz moves to a realistic schedule, BlitzResults gets a cleanup, and the report stops calling noise a change. And since the agent has now guessed wrong about its own runs, I'll check what it says it did against SQL Server's own record of what BlitzReader actually ran.
More to Read
sqlfingers: Build the Fence Before Letting in the AI: My sp_Blitz App, Part 1
Brent Ozar: SQL Server First Responder Kit
Brent Ozar: Using Claude Code with SQL Server and Azure SQL DB

































