
I want AI agents that run autonomously on a cron schedule or in response to events, managed by Kubernetes.
The demo task is a daily AI news digest. Every morning at 8am, an agent searches the web for yesterday's AI news, writes a digest to the wiki, and commits. It's also triggerable on demand via webhook.
K8s handles the scheduling. A custom controller watches a CRD and creates Jobs. The agent runs in a container, writes to a shared volume, and the controller tracks the result. Agents commit locally but don't push. I review and push manually.
I wrote a new Claude Code agent for this: journalist. Sonnet, not Opus. This runs daily, and the task is search and summarize. Sonnet is cheaper and good enough.
The agent searches for yesterday's AI news, writes a digest to
wiki/journal/YYYY-MM-DD/ai-news.md with source URLs for every
item, and commits. It determines the date itself via date.
Early versions used WebSearch, which burned through context
with HTML scraping. I built a small
GNews MCP server
that wraps the GNews API. Structured JSON responses, fewer
tokens. The agent runs six parallel queries (OpenAI,
Anthropic, Google AI, NVIDIA AI, AI startup, plus
tech headlines) and deduplicates the results.
I also wrote a
Discord MCP server
so the agent posts its digest to the #news channel in my
Discord bot server.
graph TD
AT[daily-ai-news AgentTask] -->|watched by| CTRL[Controller]
CTRL -->|8am cron| JOB[K8s Job]
JOB -->|init| GIT[git fetch + reset]
GIT -->|then| AGENT[Journalist Agent]
AGENT -->|writes| WIKI[wiki/journal/YYYY-MM-DD/ai-news.md]
AGENT -->|commits| PVC[Shared PVC]
AGENT -->|posts via MCP| DISCORD[Discord #news]
WH[Webhook :8080] -->|creates| AT
The controller is a Go binary running as a Deployment. It watches
AgentTask custom resources in the ai-agents namespace.
Each Job has two containers:
alpine/git): fetches the repo, resets
to the configured branch, then chowns the workspace to
uid 1000 (the agent user).ai-agent-runtime): writes MCP config,
then runs claude --mcp-config ... --agent journalist -p "..." --allowedTools ...The
controller
is a Go reconcile loop that lists all AgentTasks every 30 seconds.
For scheduled tasks, it compares lastRunTime against the cron
expression and creates a Job when due. For manual and webhook
tasks, it creates a Job immediately when phase is Pending.
The runtime image is an Alpine container with Claude Code and
OpenCode baked in (infra/ai-agent-runtime/Dockerfile). Runs
as uid 1000.
The custom resource definition gives kubectl get agenttasks
for free. Here's the daily news task:
apiVersion: agents.kyle.pericak.com/v1alpha1
kind: AgentTask
metadata:
name: daily-ai-news
spec:
agent: journalist
runtime: claude
prompt: >-
Search for yesterday's most notable AI news...
schedule: "0 8 * * *"
trigger: scheduled
readOnly: false
allowedTools: >-
WebSearch,WebFetch,Read,Glob,Grep,Write,
Bash(mkdir *),Bash(git add *),
Bash(git commit *),Bash(date *)
The controller splits allowedTools on commas and passes each
one as a separate --allowedTools flag to Claude Code. This is
how you run Claude Code autonomously without
--dangerously-skip-permissions.
| Field | Purpose |
|---|---|
agent |
Name from .claude/agents/ |
runtime |
claude or opencode |
prompt |
The -p argument |
schedule |
Cron expression (empty = one-shot) |
trigger |
manual, scheduled, or webhook |
readOnly |
Whether this agent modifies the repo |
allowedTools |
Comma-separated tool permissions |
Claude Code normally uses ANTHROPIC_API_KEY to talk to
Anthropic's API directly. In a container, I route it through
OpenRouter instead. One API key
for all model providers, unified billing.
The Helm chart's Secret injects these env vars into every agent container:
ANTHROPIC_BASE_URL: https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN: <openrouter-key>
ANTHROPIC_API_KEY: ""
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
ANTHROPIC_API_KEY must be empty or Claude Code tries to use
it and the auth conflicts.
Claude Code prompts for permission before using tools. In a
container with no TTY, it just hangs. The fix is
--allowedTools, which auto-approves specific tools.
Each tool needs its own flag. Comma-separated doesn't work:
# wrong
claude -p "..." --allowedTools "WebSearch,Write,Read"
# right
claude -p "..." \
--allowedTools "WebSearch" \
--allowedTools "Write" \
--allowedTools "Read"
Tools with specifiers use parentheses for scoping:
--allowedTools 'Bash(mkdir *)' # only mkdir
--allowedTools 'Bash(git add *)' # only git add
--allowedTools 'Write' # any file
I tried scoping Write to a specific path with
Write(apps/blog/blog/markdown/wiki/journal/**) but it didn't
match inside the container. The pattern works locally but fails
when the working directory is /workspace/repo. Unrestricted
Write works. The agent definition and Bash scoping still
constrain what gets written.
The controller exposes :8080/webhook for on-demand runs.
Requires a bearer token via AI_WEBHOOK_TOKEN env var (skipped
if empty for local dev):
curl -X POST http://localhost:8080/webhook \
-H "Authorization: Bearer $AI_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "journalist",
"prompt": "Search for today AI news...",
"allowedTools": "WebSearch,WebFetch,Read,Write,..."
}'
The handler creates an AgentTask CR with the allowedTools, and the reconcile loop picks it up.
The Helm chart packages everything: CRD, controller Deployment, ServiceAccount, RBAC, Secrets, and PVC. hostPath volume, single node.
helm install agent-controller ./helm \
-n ai-agents --create-namespace \
-f values-override.yaml
The OpenRouter key has special characters that --set mangles.
Use a values file instead:
secrets:
openrouterApiKey: "<your-openrouter-key>"
discordBotToken: "<your-discord-bot-token>"
discordGuildId: "<your-guild-id>"
repo:
branch: kyle/blog-k8s-autonomous-agents-mvp