Skip to content
Docs

Draft content in your voice from Slack with Eve

Deploy the Eve content agent template, a Slack bot that drafts blog posts, LinkedIn posts, release notes, and newsletters in your house voice from Notion, using Vercel Connect, Vercel Blob, and the AI Gateway.

13 min read
Last updated June 17, 2026

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, a filesystem-first framework for durable backend agents from Vercel. Every credential is brokered at runtime: Slack and Notion authenticate through Vercel Connect, and Vercel Blob and the model 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.

Eve Content Agent Template

A Slack bot that drafts blog posts, LinkedIn posts, release notes, and newsletters in your house voice with Notion.

Deploy Template

If you're working with an AI coding agent like Claude Code or Cursor, you can clone the template and hand off setup with this prompt:

AI Prompt
I want to deploy and customize the Eve content agent template.
Clone the repo at https://github.com/vercel-labs/eve-content-agent-template, then read AGENTS.md and ARCHITECTURE.md to understand how it's built and the conventions to follow.
Install dependencies with pnpm, then help me run it locally with `pnpm dev` and deploy with `eve deploy`. When searching for information, check for applicable skill(s) first and review the local Eve docs in node_modules/eve/docs.

The 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.

Terminal
npx plugins add vercel/vercel-plugin

You need three accounts to deploy and run the agent:

  • A Vercel account.
  • A Slack workspace where you can install an app. The Deploy button installs a Vercel-managed Slack app for you.
  • 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.

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.

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.

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.

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:

Terminal
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:

Terminal
pnpm dev

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

Terminal
eve deploy

Use eve deploy rather than vercel deploy. Eve is an experimental framework that Vercel's default deploy can't auto-detect; eve deploy wraps vercel deploy --prod, installs dependencies, and pulls environment variables.

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 one session, so context carries, 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 (such as an exported draft, an image, or an attachment) and return URLs that the agent can reuse. Every credential in this loop is brokered at runtime: Slack and Notion through Vercel Connect, Blob and the model through the project's OIDC token. No static keys live in the code.

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 is one file. It hands Eve a set of Slack credentials brokered by Vercel Connect:

agent/channels/slack.ts
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.

Notion is a Model Context Protocol (MCP) connection, authorized per writer through Vercel Connect:

agent/connections/notion.ts
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.

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:

agent/skills/blog-style/SKILL.md
---
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 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:

agent/skills/blog-style/references/banned-words.json
[
"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.

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:

agent/tools/lint_against_style.ts
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.

Five tools store and manage files in Vercel Blob: upload_asset, list_assets, get_asset_info, download_asset, and delete_asset. upload_asset is representative:

agent/tools/upload_asset.ts
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:

agent/tools/delete_asset.ts
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 makes Slack render an approve-or-deny button before the deletion runs, so the model can never delete a file on its own.

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:

agent/agent.ts
import { defineAgent } from "eve";
export default defineAgent({
model: "anthropic/claude-opus-4.8",
});

The model id routes through the Vercel 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.

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.

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

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.

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.

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.

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.

Symptom: vercel deploy reports The projectSettings object is required for new projects, or vercel deploy --prebuilt finds no prebuilt output.

Cause: Eve is an experimental framework that Vercel's default deploy can't auto-detect, and eve build writes to .output rather than .vercel/output.

Fix: Deploy with eve deploy, which wraps vercel deploy --prod, installs dependencies, and pulls environment variables.

No. Authentication runs entirely on Vercel Connect (Slack and Notion) and the project's OIDC token (Vercel Blob and the AI Gateway). Notion is authorized per writer in the browser, and there are no API keys or client secrets to store in code or .env files.

Not the Slack surface itself. Slack forwards events to Vercel Connect, which delivers them to your deployed project rather than to a local URL, so test the inbound Slack path against a preview or production deployment. The drafting, style-lint, Notion, and Blob flows all run in the local dev TUI with pnpm dev.

Yes. The Notion connection is user-scoped through Vercel Connect, so each writer signs in once and the agent acts with that person's Notion permissions. Drafts are created by the real author, and there's no shared integration token.

Edit the per-surface style skill in agent/skills/<surface>-style/SKILL.md and the references/banned-words.json it lints against. To add a surface, add a new skill folder, add the surface to the enum in agent/tools/lint_against_style.ts, and mention it in agent/instructions.md.

Eve is an experimental framework that Vercel's default deploy can't auto-detect, and eve build writes its output to .output. eve deploy wraps vercel deploy --prod, installs dependencies, and pulls environment variables, so use it to ship.

Was this helpful?

supported.

Read related documentation