Recipe — coding agent on chat
A long-running coding workflow reachable via webhook (Telegram, Slack, generic HTTP). It receives a message, decides whether it is a coding request, makes the change in a persistent repo workspace, opens a PR, and replies.
This recipe exercises:
- Webhook-triggered scheduling
- A persistent agent workspace with
gitRoot: true - Agent definition in
AGENTS.md+SOUL.md - Skill-packaged GitHub access
ctx.statefor idempotency on inbound messages- Default-deny permissions narrowed to exactly what the agent needs
Project layout
coding-agent-on-chat/
├── skelm.config.ts
├── workflows/
│ └── coding.workflow.ts
├── agents/
│ └── coder/
│ ├── AGENTS.md
│ └── SOUL.md
├── skills/
│ ├── github-write/SKILL.md
│ └── chat-reply/SKILL.md
├── secrets/
│ └── default.json # gitignored
├── package.json
└── tsconfig.jsonskelm.config.ts
ts
import { defineConfig } from 'skelm'
export default defineConfig({
backend: 'copilot-acp',
backends: {
'copilot-acp': { command: 'mcp-copilot-acp' },
},
defaults: {
permissions: {
networkEgress: 'deny',
allowedExecutables: [],
allowedTools: [],
allowedSkills: [],
allowedMcpServers: [],
fsRead: [],
fsWrite: [],
},
},
storage: {
workspaces: { base: '~/.skelm/workspaces' },
},
secrets: { driver: 'env' },
server: { port: 4000, host: '127.0.0.1', auth: { mode: 'none' } },
})agents/coder/AGENTS.md
markdown
---
name: coder
description: Reads a chat message; if it asks for a code change, makes the change in a checked-out repo, opens a PR, and replies.
version: 1
metadata:
skelm:
role: coder
expectedTurns: long
requires:
bins: ['git', 'pnpm', 'rg']
mcpServers: ['gh', 'chat']
skills: ['github-write', 'chat-reply']
grants:
tools: ['gh.create_pr', 'gh.add_comment', 'chat.send_message']
---
# Coder
You are a careful coder. Given a chat message and a checked-out repository, you make minimal targeted changes.
## Method
1. Read the message. Decide whether it is a coding request, a question, or chat.
2. If it is a coding request:
a. `git pull` the workspace.
b. Identify the smallest change that addresses the request.
c. Make the change. Run `pnpm test` if a test suite exists.
d. Commit on a feature branch.
e. Open a PR via `gh.create_pr`.
f. Reply with a link to the PR via `chat.send_message`.
3. If it is a question, answer it concisely; do not modify code.
4. If it is chat, reply briefly and stop.
## Boundaries
- Never modify CI configuration unless the request is explicitly about CI.
- Never delete files unless the request explicitly requests deletion.
- If the change requires more than ~200 lines, ask for confirmation in chat before proceeding.agents/coder/SOUL.md
markdown
# Soul
You are calm, terse, and conservative. You quote the relevant code lines when explaining a change. You do not add motivational filler.
## Standing reminders
- Prefer the smallest correct change.
- A failing test is a stop sign.
- "I'm not sure" is a complete answer when warranted.skills/github-write/SKILL.md
markdown
---
name: github-write
description: Provides write access to GitHub via the `gh` MCP server.
version: 1
metadata:
skelm:
requires:
mcpServers: ['gh']
grants:
tools: ['gh.create_pr', 'gh.add_comment', 'gh.list_pulls']
---
# GitHub write capability
Tools available:
- `gh.create_pr` — open a pull request from a branch.
- `gh.add_comment` — comment on an issue or PR.
- `gh.list_pulls` — list open PRs.skills/chat-reply/SKILL.md
markdown
---
name: chat-reply
description: Sends replies to the originating chat channel.
version: 1
metadata:
skelm:
requires:
mcpServers: ['chat']
grants:
tools: ['chat.send_message']
---
# Chat reply capability
Use `chat.send_message` to reply to the chat that triggered this workflow.workflows/coding.workflow.ts
ts
import { pipeline, code, agent } from 'skelm'
import { z } from 'zod'
const inboundMessage = z.object({
messageId: z.string(),
channel: z.string(),
sender: z.string(),
text: z.string(),
})
export default pipeline({
id: 'coding-on-chat',
description: 'Coding agent reachable via webhook.',
input: inboundMessage,
output: z.object({
handled: z.boolean(),
prUrl: z.string().optional(),
reply: z.string().optional(),
}),
steps: [
code({
id: 'idempotency-check',
run: async (ctx) => {
const seen = await ctx.state.get<boolean>(`msg:${ctx.input.messageId}`)
return { alreadySeen: !!seen }
},
}),
code({
id: 'mark-seen',
run: async (ctx) => {
if (!ctx.steps['idempotency-check'].alreadySeen) {
await ctx.state.set(`msg:${ctx.input.messageId}`, true, { ttlMs: 1000 * 60 * 60 * 24 * 30 })
}
return {}
},
}),
agent({
id: 'work',
backend: 'copilot-acp',
agentDef: './agents/coder',
skills: ['github-write', 'chat-reply'],
mcp: [
{ id: 'gh', transport: 'stdio', command: 'mcp-github' },
{ id: 'chat', transport: 'stdio', command: 'mcp-chat-bridge' },
],
workspace: { mode: 'persistent', name: 'main', gitRoot: true },
permissions: {
allowedTools: [
'gh.create_pr', 'gh.add_comment', 'gh.list_pulls',
'chat.send_message',
],
allowedExecutables: ['git', 'pnpm', 'rg'],
allowedMcpServers: ['gh', 'chat'],
allowedSkills: ['github-write', 'chat-reply'],
networkEgress: { allowHosts: ['api.github.com', 'chat.bridge.local'] },
// fsRead / fsWrite default to the workspace root via the intersection rule.
},
prompt: (ctx) => `New chat message from ${ctx.input.sender} in ${ctx.input.channel}:\n\n${ctx.input.text}`,
output: z.object({
handled: z.boolean(),
prUrl: z.string().optional(),
reply: z.string().optional(),
}),
maxTurns: 30,
}),
code({
id: 'journal',
run: async (ctx) => {
await ctx.state.append('decisions', {
at: Date.now(),
messageId: ctx.input.messageId,
handled: ctx.steps.work.handled,
prUrl: ctx.steps.work.prUrl,
})
return {}
},
}),
],
finalize: (ctx) => ({
handled: ctx.steps.work.handled,
prUrl: ctx.steps.work.prUrl,
reply: ctx.steps.work.reply,
}),
})Schedule it
sh
# Register a webhook schedule
skelm schedule add workflows/coding.workflow.ts \
--webhook /webhooks/chat \
--id coding-chat-bridge \
--overlap skip
# Start the gateway
skelm gateway startExternal chat-bridge service POSTs to http://gateway-host:4000/webhooks/chat with the inbound message body matching the input schema. Each POST fires one run.
Why each piece is here
- Persistent workspace +
gitRoot: true— the agent keeps a checked-out copy of the repo across runs. Git history doubles as a transcript of what the agent has changed over time. - Idempotency check + journal — repeated webhook fires of the same
messageIdare no-ops at the agent layer; decisions are journalled for review. overlap: skip— if the previous run is still in flight when a new message arrives, skip. Prevents workspace lock thrash.- Narrow
permissions— agent can only call the listed tools, run the listed binaries, and reach the listed hosts. The workspace intersection means it can read/write only inside the checkout. - Skill packages —
github-writeandchat-replyare reusable across multiple coding workflows. The role agent definition references both.
Inspecting what the agent did
sh
skelm history --workflow coding-on-chat --since 7d
skelm history --run <runId> --events
skelm audit query --workflow coding-on-chat --category permission.denied --since 7dThe audit log shows every permission denial; if the agent kept trying to do something it cannot, you will see it here. Add the tool to allowedTools only if the action is truly intended.