Client feedback has a way of dying in Slack. Someone drops a note in a channel, three people react, and by Thursday nobody remembers whether it got handled. I wanted a teammate whose whole job was to catch every one of those messages, track it, and tell me later what actually happened.
So I built one on Eve, Vercel's new agent framework. This is what it does, how it's put together, and the bugs that only showed up once real Slack traffic was running through it.
What the agent does
It watches a Slack channel where clients drop feedback. Each message becomes a tracked action item. If two messages are about the same thing, it links them instead of creating a duplicate. It reads the follow-up threads under each item to figure out what the team has already finished. Then it delivers two kinds of summaries: a daily digest to a team channel, and an on-demand recap straight to your DM when you ask.
The one rule I cared about most: it never makes up feedback. Every item keeps a link back to the Slack message it came from, and the reports are built only from what it captured. If it didn't capture it, it can't report it, and every line points at its source.
It runs on `anthropic/claude-sonnet-4.6`, ships as a Next.js app on Vercel, and stores everything in Upstash Redis.
What Eve gives you
Eve is a framework for building agents the way you'd build the rest of a Vercel app: in code, with typed primitives, deployed next to your Next.js shell. You describe the agent and its capabilities, and the runtime handles the model calls, the tool loop, background execution, and the deploy.
The pieces I used, by the numbers:
- The agent itself: one `defineAgent`, pinned to Sonnet 4.6.
- Instructions: one file, the behavioral spec.
- Tools: nine `defineTool`s for capture, dedup, reconcile, and delivery.
- Skills: one, the judgment rules for capture.
- Schedules: one `defineSchedule`, the daily cron digest.
- Channels: two `defineChannel`s, the built-in Eve channel plus custom Slack slash commands.
- Evals: seven `defineEval`s, run against stubbed Slack and Redis.
- Support libraries: four, pure logic plus Slack and Redis adapters.
The thing I want to get across is how the responsibilities split. Judgment lives in skills, plumbing lives in tools, and layout lives in instructions. That split is most of why the project stayed easy to reason about.
The agent
The agent itself is almost nothing:
// agent/agent.ts
export default defineAgent({
model: "anthropic/claude-sonnet-4.6",
})All the personality is in the instructions file, not in code. Sonnet was the right tier for this. I tried Haiku first and it missed about half the action items in a batch, which is disqualifying for a tool whose whole point is not to miss things. Opus cost more and didn't extract anything Sonnet didn't. Sonnet 4.6 landed where I needed it.
Instructions: the agent's brain
`agent/instructions.md` is the spec the model runs against. It defines what counts as an action item, how to tell a client message apart from team chatter, the capture loop, the reconcile step, and the exact layout of the digest. It also has a voice section that bans em dashes, emoji, hype words, and raw Slack user IDs, so the output reads like a teammate typing rather than a bot.
Keeping the layout in the instructions meant I could change how the digest looks without touching a line of TypeScript.
Tools: the plumbing
Every tool is a `defineTool` with a Zod schema on its input. There are nine, in four groups.
Capture is one tool. `fetchmessagesin_range` drains new channel messages since a watermark, five at a time, and reports how many are left so the agent keeps looping until the backlog is empty.
Dedup and storage is four. `listopenitems` shows the model what's already open so it can choose merge or new. `saveitem` creates a fresh action item. `mergeitem` folds a new message into an existing item and re-synthesizes the detail while keeping every source link. `listitemsin_range` pulls items from a date window for the digest and the recap.
Reconcile and resolve were the additions that made the agent actually trustworthy. `reconcileopenitems` re-reads the Slack thread under each open item and hands the team's replies back to the model. `updateitemstatus` closes an item the team clearly finished, or tags an in-flight one with a short note like "ticket opened".
Delivery is the last two. `posttochannel` posts the digest to the team channel, and `senddm` DMs a recap to whoever asked, with a `responseurl` fallback if the DM fails.
A tool sketch, so the shape is concrete:
export const saveItem = defineTool({
name: "save_item",
input: z.object({
summary: z.string(),
sourceMessageId: z.string(),
permalink: z.string().url(),
}),
run: async ({ summary, sourceMessageId, permalink }) => {
return store.createItem({ summary, sourceMessageId, permalink })
},
})The `permalink` is the anti-hallucination seatbelt. Nothing gets stored without a link back to where it came from.
The skill: judgment the tools can't hold
`capture-feedback` is a single skill that holds the decisions code can't cleanly encode. How to spot a real action item. When to skip banter or internal chatter. When to merge versus create. When a thread reply means "done" versus "opened a ticket, still working on it". Eve loads it on demand while the agent is processing messages, so the instructions file stays lean and the judgment lives in one place I can edit like prose.
The schedule: a morning digest
The daily digest is a `defineSchedule` on `0 7 * * *`, which is 07:00 UTC, a morning digest where I am. Vercel crons run in UTC and need to return fast, so the cron doesn't do the work itself. It enqueues a background run, and that run captures, reconciles, then composes and posts the digest. The schedule's markdown tells the agent that sequence in plain language.
Channels and Slack slash commands
This is the part I knew least about going in, so it's the part I learned the most from.
There are two channels. `eve.ts` is the built-in Eve channel that lets the Eve TUI and my deployments reach the agent. The interesting one is `slack-commands.ts`, a custom `defineChannel` that owns two Slack slash-command webhooks.
Getting Slack to talk to the agent meant learning how a Slack app actually fits together: you create the app, give the bot the token scopes it needs to read history and post messages, add slash commands that point at your webhook URLs, and grab the signing secret so you can verify that incoming requests are really from Slack. Every request gets its signature checked before anything else happens.
The constraint that shapes everything is Slack's three-second rule. A slash command has to get a response within three seconds or Slack shows the user an error, and no real agent run finishes that fast. So the pattern is: verify the signature, acknowledge immediately inside the window, and kick the real work off in the background.
// inside the /client-recap route
if (!verifySlackSignature(req)) return unauthorized()
// ack now, work later
runRecapInBackground(parseCommand(req))
return ack("On it. Your recap is on the way to your DMs.")`/client-recap` does exactly that, then runs the recap in the background and DMs it over. `/recap-config` is the second command, and it repoints the feedback or digest channel at runtime by writing to Redis, so I can reconfigure the agent from inside Slack without a redeploy. Both routes live under `/eve/v1/` because that is the only prefix the Next.js and `withEve` deploy forwards to the Eve runtime.
Evals: testing behavior, not wording
There are seven evals, run with `pnpm eval`, which blanks the Slack and Upstash env vars so the tests hit stubs and never touch the real workspace or production data. They cover extraction, dedup, keeping distinct items distinct, ignoring noise, parsing time periods like "last 24 hours", the output voice, and resolution, which is the thread-reconcile behavior.
They assert on which tools got called and whether the run succeeded, not on the model's exact words. The wording is non-deterministic, so testing for it would make the suite flaky. Testing the behavior is what actually tells me the agent still works.
Deployment
`withEve(nextConfig)` writes the Vercel output config that mounts the Next.js shell at `/` and the Eve runtime under `/eve/v1/*`. I deploy with `pnpm exec eve deploy`, which flips the experimental-frameworks flag, builds both services, and ships to production. Storage is Upstash Redis, and secrets live in Vercel and Shelve.
What real Slack traffic taught me
Four bugs and habits I only found by running this against a live workspace.
Thread replies are a separate API call
`conversations.history` only returns top-level messages, so a team member's "shipped it" reply inside a thread was completely invisible to the agent. Adding `conversations.replies` fixed the blind spot, but that endpoint rejects a JSON body with `invalid_arguments` and has to be form-encoded, which `conversations.history` does not. My offline tests never caught it because they stub Slack. One live run against a real thread found it in seconds.
Runtime config beats redeploys, with a catch
The channels live in Redis with env vars as a fallback, so `/recap-config` can repoint them from inside Slack. The thing you have to document, or you will confuse yourself later: the Redis value overrides the env var, so changing the env in Vercel does nothing until you clear or update the Redis key.
A forward-only watermark needs a reconcile pass
Because capture only ever moves forward and never re-reads old messages, a "done" reply posted an hour after the original would be missed forever. The fix was the reconcile step: on every run, re-read the open items' threads so late resolutions still land. That one pass is the difference between a report you trust and a report that quietly goes stale.
Judgment in skills, plumbing in tools, layout in instructions
This is the same split from the top of the post, and living with it confirmed it. Each file stayed small enough to hold in my head, and the evals stayed meaningful because each layer had one job.
Where it leaves me
The agent has been quietly turning a noisy feedback channel into a list I can trust, with a recap a slash command away and a link under every line so I can always check the source. If your team lives in Slack and loses track of what clients ask for, this is the kind of thing I'd build for you. That is the honest pitch, and the code behind it is the rest of this post.


