# Draft content in your voice from Slack with eve

**Author:** Ben Sabic

---

Draft blog posts, LinkedIn posts, release notes, and newsletters in your team's house voice without leaving Slack. You @mention the bot, point it at your source material in Notion, and it loads the right style guide, drafts in your voice, takes your edits in the thread, and publishes the approved piece back to Notion as you.

The bot is built on [eve](https://beta.eve.dev), a filesystem-first framework for durable backend agents from Vercel. Every credential is brokered at runtime: Slack and Notion authenticate through [Vercel Connect](https://vercel.com/docs/connect), and [Vercel Blob](https://vercel.com/docs/vercel-blob) and [AI Gateway](https://vercel.com/ai-gateway) authenticate with your project's OpenID Connect (OIDC) token. There are no API keys or client secrets to set.

Deploy the template now, or read on for a deeper look at how it all works.

## Quick start with an AI coding agent

If you're working with an AI coding agent like Claude Code or Cursor, you can use this prompt to have it help you with building your agent:

### Vercel Plugin

The [Vercel Plugin](https://vercel.com/docs/agent-resources/vercel-plugin) turns your AI coding agent (e.g., OpenAI Codex, Claude Code, or Cursor) into a Vercel expert. It adds skills, slash commands, and current knowledge of the tools this template uses, including Vercel Connect, Vercel Blob, and AI Gateway. The plugin is optional; it isn't required to use eve or to follow this guide.

`npx plugins add vercel/vercel-plugin`

## Setup and deployment

### What you need before deploying

You need three accounts to deploy and run the agent:

- A [Vercel account](https://vercel.com/signup).
  
- A Slack workspace where you can install an app. The deploy button flow will help you install a Vercel-managed Slack app.
  
- A Notion workspace for source material and drafts. Each writer signs in to their own Notion the first time they use the bot, so you don't need a shared integration token.
  

For local development, you also need Node.js 24 or newer, pnpm, and the [Vercel CLI](https://vercel.com/docs/cli).

### Deploy to Vercel

Deploying with the button provisions every service the agent needs and wires them together:

- A **Slack** connector, with its event trigger pointed at the route eve serves (`/eve/v1/slack`) and the connector UID stored in `SLACK_CONNECTOR`.
  
- A **Notion** connector, with its UID stored in `NOTION_CONNECTOR`.
  
- A public **Vercel Blob** store for the asset tools.
  

The connectors use Vercel Connect, so you authorize Vercel's managed Slack and Notion apps in the browser instead of registering OAuth clients or copying secrets.

When the deploy finishes, your agent is live and wired up. All that's left is to bring it into Slack and have writers sign in to Notion on first use.

### Connect Slack

The Deploy button installs the Slack app and points its event trigger at the route the agent serves (`/eve/v1/slack`), so there's nothing to wire up. After the deploy finishes, invite the bot to a channel in your workspace and @mention it to start a thread.

You can't test the Slack surface locally, because Slack forwards events to Vercel Connect, which verifies them and delivers them to your deployed project rather than to a local URL. Test the inbound Slack path against a preview or production deployment; everything else runs in the local dev TUI.

### Authorize Notion

The first time a writer asks the bot to read from or publish to Notion, Vercel Connect prompts them to sign in to Notion in the browser. That sign-in is per writer, so the agent acts as the signed-in person using their own Notion permissions. A draft is created by the real author, and there's no shared integration token to manage.

Because the connection is user-scoped, the agent reads and writes only what each writer can already access. When a writer approves a draft, the bot creates it as a new page in the team's Drafts database in Notion and replies with the link.

### Local development

You can run the full agent locally in a terminal UI (TUI), with one exception: the Slack surface only runs against a deployment. Link the project you deployed and pull its environment first:

`vercel link vercel env pull`

`vercel env pull` writes a short-lived OIDC token into `.env.local`, which authenticates Vercel Blob and the model locally. Re-run it when the token expires. Then start the dev server and link a model provider with `/model` in the TUI:

`pnpm dev`

In the TUI you can exercise the drafting, style-lint, Notion, and Blob flows directly. Ship changes to production with `eve deploy`:

`eve deploy`

Use `eve deploy` rather than `vercel deploy`. The command wraps `vercel deploy --prod`, installs dependencies, and pulls environment variables.

### How the content agent works

The agent runs one loop: match the voice, ground the draft in Notion, self-check, propose, and publish only after you approve.

1. **@mention in Slack.** A writer @mentions the bot or DMs it and names a surface (blog, LinkedIn, release notes, or newsletter). The Slack channel turns the message into a session and threads every reply.
   
2. **Load the style skill.** The agent loads that surface's style skill, which carries the voice rules, structure, and (for blog) a canonical example post. Skills load on demand, so a skill enters context only when the surface calls for it.
   
3. **Read source material from Notion.** The agent searches the writer's Notion for the briefs, product notes, and past posts they point to, and reads them before drafting. It discovers the Notion tools at runtime and calls them as the signed-in writer.
   
4. **Draft and self-check.** The agent drafts in the house voice, then runs the `lint_against_style` tool to catch banned words for that surface and fixes them before showing anything.
   
5. **Iterate in the thread.** The writer reviews the draft and asks for changes, such as "tighten the intro" or "less corporate." The thread is a single session, so context carries over, and the agent revises in place.
   
6. **Publish on approval.** Only when the writer explicitly approves does the agent write to Notion, creating the piece as a new page in the team's Drafts database and replying with the link.
   

Along the way, the Vercel Blob tools store any durable files (e.g., exported drafts or images) and return URLs that the agent can reuse.

Every credential in this loop is brokered at runtime:

- Slack and Notion through Vercel Connect
  
- Vercel Blob and AI Gateway through the OIDC token
  

No static keys live in the code.

## Code walkthrough

The whole agent is defined under `agent/`, and eve discovers each capability from the filesystem. A tool's, connection's, or channel's filename is its name, so there's no central registry to wire up. These are the pieces that matter.

### The Slack surface, `agent/channels/slack.ts`

The Slack surface is one file. It hands eve a set of Slack credentials brokered by Vercel Connect:

`import { connectSlackCredentials } from "@vercel/connect/eve"; import { slackChannel } from "eve/channels/slack"; export default slackChannel({ credentials: connectSlackCredentials( process.env.SLACK_CONNECTOR ?? "slack/eve-content-agent" ), });`

`connectSlackCredentials` reads the connector UID from `SLACK_CONNECTOR` and lets Vercel Connect handle token rotation, multi-workspace tenancy, and webhook verification, so none of that lives in your code. Because the file is `slack.ts`, eve registers it as the `slack` channel and serves its events at `/eve/v1/slack`. Each human Slack user becomes a per-user principal, which allows the agent to act in Notion as that specific writer.

### Reading and writing Notion, `agent/connections/notion.ts`

Notion is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) connection, authorized per writer through Vercel Connect:

`import { connect } from "@vercel/connect/eve"; import { defineMcpClientConnection } from "eve/connections"; const notionConnector = process.env.NOTION_CONNECTOR ?? "notion"; export default defineMcpClientConnection({ url: "https://mcp.notion.com/mcp", description: "Notion workspace: search, read, and edit pages and databases.", auth: connect(notionConnector), });`

As with the Slack channel, the connector UID comes from an environment variable (`NOTION_CONNECTOR`).

The `"notion"` fallback is a default placeholder; the real UID takes the same `<service>/<name>` form as Slack, which for Notion is `mcp.notion.com/notion`. `connect()` makes the connection user-scoped: each writer signs in once, and the agent calls Notion with that writer's token.

The model never sees the URL or the token. It discovers the available Notion tools at runtime through the built-in `connection__search` and calls them by their qualified name, such as `connection__notion__notion-search`. The template sets no `tools.allow` or `tools.block` list, so the full Notion tool set (search, read, and write) is available, and the `description` field is the hint the model uses to decide when to reach for Notion.

### House voice as skills, `agent/skills/`

Each content surface has its own style skill: a folder under `agent/skills/` with a `SKILL.md` and supporting reference files. The blog skill reads like this:

`--- description: Use when drafting or editing a blog post or long-form article in the house voice. --- # Blog voice & style When writing or editing a blog post: - Lead with the reader's problem, not the product. - Short paragraphs, 2–4 sentences, one idea each. - Active voice. Cut hedges: "just", "simply", "very", "really". - Concrete over abstract; show an example before stating a principle.`

The `description` in the frontmatter is a routing hint, not a label. eve exposes it to the model alongside a built-in `load_skill` tool, and the model loads the skill only when a request matches the description. Loading a skill only adds instructions to the turn, it never adds a new action the agent can take.

Two reference files sit beside each `SKILL.md`. `references/banned-words.json` is a flat list of words to avoid, which the style-lint tool reads:

`["leverage", "synergy", "revolutionary", "game-changer", "seamless", "cutting-edge", "robust" ]` The blog skill also ships `references/good-post.md`, a canonical example the model reads as a few-shot before drafting to absorb the voice. ### Enforcing style, `agent/tools/lint_against_style.ts` The style skills describe the voice; this tool enforces the part that can be checked deterministically. It reads the active surface's `banned-words.json` and flags any whole-word match in the draft: ``import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ description: "Check a draft against the active surface's style rules and return any violations. " + "Run before proposing a draft to the writer.", inputSchema: z.object({ surface: z.enum(["blog", "linkedin", "release-notes", "newsletter"]), text: z.string().min(1).max(100_000), }), async execute({ surface, text }, ctx) { const raw = await ctx .getSkill(`${surface}-style`) .file("references/banned-words.json") .text(); // Parse the list, then flag whole-word matches in `text`. // ... }, });`` The instructions tell the agent to run this before proposing a draft and to treat it as a floor, not a ceiling. Two details make it safe to run on model-supplied text: the `surface` input is a fixed enum, so the resolved skill path can never be influenced by the caller, and each banned word is regex-escaped before matching to prevent a catastrophic-backtracking pattern (ReDoS). The accepted draft length is bounded at 100,000 characters. ### Storing assets in Blob, `agent/tools/upload_asset.ts` Five tools store and manage files in Vercel Blob: `upload_asset`, `list_assets`, `get_asset_info`, `download_asset`, and `delete_asset`. Here’s how the `upload_asset` tool looks: `import { put } from "@vercel/blob"; import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ description: "Upload text or binary content to Vercel Blob storage and return its URL. " + "Use when the writer wants to save or publish an asset to durable storage.", inputSchema: z.object({ pathname: z.string().min(1), content: z.string(), contentType: z.string().optional(), isBase64: z.boolean().optional(), access: z.enum(["public", "private"]).optional(), }), outputSchema: z.object({ success: z.boolean(), url: z.string(), downloadUrl: z.string(), pathname: z.string(), contentType: z.string(), error: z.string().optional(), }), async execute({ pathname, content, contentType, isBase64, access }) { const body = isBase64 ? Buffer.from(content, "base64") : content; const blob = await put(pathname, body, { access: access ?? "public", contentType, }); return { success: true, url: blob.url, downloadUrl: blob.downloadUrl, pathname: blob.pathname, contentType: blob.contentType, }; }, });` The Blob tools authenticate with the project's OIDC token (`VERCEL_OIDC_TOKEN`, or the `x-vercel-oidc-token` header on Vercel), so there's no `BLOB_READ_WRITE_TOKEN` to set and no token in code. Two of the tools are hardened for an agent that runs on model output. `download_asset` fetches only URLs whose host ends in `.blob.vercel-storage.com`, which stops the model from using it to reach arbitrary internal addresses. `delete_asset` is irreversible, so it's gated on human approval: `import { del } from "@vercel/blob"; import { defineTool } from "eve/tools"; import { always } from "eve/tools/approval"; import { z } from "zod"; export default defineTool({ description: "Permanently delete an asset from Vercel Blob storage by its URL. " + "Use only when the writer explicitly asks to remove a stored file.", inputSchema: z.object({ url: z.url().describe("The full Vercel Blob URL of the asset to delete."), }), needsApproval: always(), async execute({ url }) { await del(url); return { success: true, deleted: true, url }; }, });` `needsApproval: always()` from `eve/tools/approval` displays a card with ‘Yes’ and ‘No’ buttons in Slack before the deletion runs, so the model can never delete a file on its own. ### Behavior and model, `agent/instructions.md` and `agent/agent.ts` `instructions.md` is the agent's always-on system prompt. eve prepends it to every model call, and it encodes the loop from the section above: match the voice, find the source material, self-check with the lint, propose in the thread, and publish only after the writer approves. The publishing contract is the rule worth calling out: the agent treats a draft as final only when the writer explicitly says to ship it, and it never writes to Notion before that approval. The model is one line: `import { defineAgent } from "eve"; export default defineAgent({ model: "anthropic/claude-opus-4.8", });` The model id routes through the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), which authenticates through the linked project, so there's no provider API key to set. To use a different model, change this string (any Gateway model id works) or run `/model` in the dev TUI.

## Add a new surface

Adding a content surface takes three edits, because eve discovers the rest from the filesystem:

1. **Add a style skill.** Create `agent/skills/<surface>-style/` with a `SKILL.md` (give it a `description` that names when to use it) and a `references/banned-words.json`. eve discovers the new skill automatically.
   
2. **Add the surface to the lint tool.** Add the new surface to the `surface` enum in `agent/tools/lint_against_style.ts` so `lint_against_style` can check drafts for it.
   
3. **Mention it in the instructions.** List the new surface in `agent/instructions.md` so the agent offers it.
   

To change the house voice for an existing surface, edit that skill's `SKILL.md` and its `banned-words.json`. The agent picks up the changes as you save. To run on a different model, edit `agent/agent.ts` or run `/model` in the TUI.

To add another chat platform, add a channel file under `agent/channels/` and a matching Vercel Connect connector. eve discovers the channel from the file the same way it discovers `slack.ts`, and Vercel Connect brokers the new platform's credentials, so the drafting loop, skills, tools, and Notion connection stay unchanged.

## Troubleshooting

Each item below lists a symptom, its cause, and the fix.

### @mentions don't get a response

**Symptom:** You @mention the bot in Slack, but it doesn't reply.

**Cause:** The bot isn't in the channel yet, or the deployment that registered the Slack trigger hasn't finished.

**Fix:** Invite the bot to the channel and confirm the deployment succeeded, then @mention it again. The Deploy button points Slack's events at the route the agent serves (`/eve/v1/slack`), so the path is already correct.

### Connectors work locally but fail in production

**Symptom:** The agent works in the dev TUI, but the deployed bot can't reach Slack or Notion.

**Cause:** `.env.local` is local-only; the deployed app reads the Vercel project's environment.

**Fix:** Set `SLACK_CONNECTOR` and `NOTION_CONNECTOR` in the project's production environment. The Deploy button sets them for you.

### The first Notion call fails in the dev TUI

**Symptom:** A Notion read or write throws a `principal_required` error in `eve dev`.

**Cause:** Notion is user-scoped, but the local TUI session authenticates as a `local-dev` principal rather than a signed-in user, so Connect won't attach a per-user token.

**Fix:** Test the Notion flow against a deployment, where each Slack writer is a real user principal. In production this doesn't happen, because Slack issues a per-user principal for every writer.

### The bot sits on "Working…"

**Symptom:** A multi-step turn shows "Working…" with no visible progress.

**Cause:** The dev TUI hides logs by default, so a long but legitimate turn looks stalled.

**Fix:** Show logs with `npx eve dev --logs all`, or run `/loglevel all` in the session.

## Related resources

- [eve content agent template](https://github.com/vercel-labs/eve-content-agent-template)
  
- [eve documentation](https://eve.dev/docs)
  
- [Vercel Connect](https://vercel.com/docs/connect) and [Vercel Connect CLI](https://vercel.com/docs/cli/connect)
  
- [Vercel Blob](https://vercel.com/docs/vercel-blob)
  
- [Vercel AI Gateway](https://vercel.com/docs/ai-gateway)
  
- [Vercel OIDC](https://vercel.com/docs/oidc)

---

[View full KB sitemap](/kb/sitemap.md)
