# Manage your Sanity project from Slack with eve

**Author:** Ben Sabic

---

Manage your Sanity project without leaving Slack. You @mention the bot, and it queries and edits content with GROQ, inspects and shapes schemas, creates and edits drafts, manages releases, and drafts long-form pieces into Notion, all as you. Destructive operations like patching, publishing, and deploying schemas pause for your approval before they run, rendered as buttons in the thread.

The bot is built on [eve](https://eve.dev/), a filesystem-first framework for durable backend agents from Vercel. Every credential is brokered at runtime: Slack, Sanity, 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 four accounts to deploy and run the copilot:

- A [Vercel account](https://vercel.com/signup).
  
- A Slack workspace where you can install an app.
  
- A Sanity project the copilot will manage.
  
- A Notion workspace for source material and drafts.
  

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 [one-click flow](https://vercel.link/sanity-copilot-eve-template) provisions everything the agent needs:

- 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 Sanity connector, with its UID stored in `SANITY_CONNECTOR`.
  
- A Notion connector, with its UID stored in `NOTION_CONNECTOR`.
  
- A public Vercel Blob store for the asset and user-preference tools.
  

When the deployment finishes, your agent is live and wired up. All that's left is to invite it to a Slack channel and have team members sign in to Sanity and Notion on first use.

### Connect Slack

The one-click flow 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 terminal UI.

### Authorize Sanity and Notion

The first time a team member asks the agent to touch Sanity or Notion, Vercel Connect will prompt them to sign in to that service in the browser. That sign-in is per user, so the agent acts as the signed-in person with their own permissions. A query runs against the datasets they can see, a draft is created by the real author, and there's no shared integration token to manage.

Because the connections are user-scoped, the agent reads and writes only what each person can already access. On top of that, destructive operations are gated on approval: the call pauses and renders approve/deny buttons in Slack before it runs.

| Connection | Operations that require approval                                                                                                                 |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Sanity     | Patching documents, publishing, unpublishing, discarding drafts, discarding versions, updating datasets, deploying schemas, deploying the Studio |
| Notion     | Updating pages, moving pages, updating data sources, updating views                                                                              |
| Blob tools | Deleting an asset, clearing a user's preferences                                                                                                 |

Reads, GROQ queries, and draft creation flow freely. Creating a new Notion page is also not gated, because drafting into Notion is the normal flow.

### Local development

You can run the agent locally in a terminal UI (TUI).

First, clone the repository that the one-click flow created under your GitHub account:

`git clone https://github.com/your-username/sanity-copilot-eve-template.git cd sanity-copilot-eve-template`

Then link it to the Vercel project you deployed and pull the environment variables:

`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 Sanity, Notion, Blob, and preference flows directly.

The template ships a dev-only auth shim (`agent/channels/eve.ts`) that presents your local session as a signed-in user, so the user-scoped Sanity and Notion connections work from the TUI without affecting production.

Push changes to production with `eve deploy`:

`eve deploy`

The `eve deploy` command is the same thing as running `vercel deploy --prod`.

The template also uses [Ultracite](https://www.ultracite.ai/) (a Biome preset) for linting and formatting.

`pnpm check` checks formatting and lint rules, `pnpm fix` auto-fixes what it can, and `pnpm validate` runs lint, typecheck, and eve's discovery diagnostics together.

### How the Sanity copilot works

The agent runs one loop: load the user's preferences and the right skill, ground the work in the real project, work in drafts, and run anything destructive past you first.

1. @mention in Slack. A team member @mentions the bot or DMs it with a Sanity task, like a GROQ query, a new draft, or a release to manage. The Slack channel turns the message into a session and threads every reply.
   
2. Load preferences and the matching skill. The agent calls `get_user_preferences` to apply that user's standing notes (a preferred dataset, tone, or workflow), then loads the skill that matches the task. Skills load on demand, so a skill enters the agent’s context only when the task calls for it.
   
3. Ground everything in the real project. The agent inspects the schema before creating or editing documents and queries with GROQ to see what actually exists, so it never invents document IDs, field names, or content. It pulls briefs and source material from Notion when you point it there. When a piece needs an outside fact, it delegates to the `researcher` subagent, which runs web research in a fresh session and returns cited findings.
   
4. Work in drafts. Sanity content is created and edited as drafts. The agent treats content as ready to publish only when you explicitly say so, and the publish call itself still pauses for an approval button.
   
5. Draft long pieces into Notion. When the destination is Notion, or the piece is long-form, the agent creates it as a new Notion page and replies with the link. A page is easier to read than a long in-thread message.
   
6. Get a fresh-eyes review. On the final draft of a piece, the agent delegates to the `reviewer` subagent, which judges the draft against a writing-quality rubric in a fresh session and returns a verdict with concrete issues. The agent addresses them before proposing the draft in the thread.
   

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, and the preference tools persist each user's standing preferences across sessions.

Every credential in this loop is brokered at runtime:

- Slack, Sanity, 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

The Slack surface is one file:

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

`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, allowing the agent to act in Sanity and Notion as that specific person and to link their preferences to them.

### Managing Sanity

Sanity is a [Model Context Protocol (MCP)](https://www.sanity.io/docs/ai/mcp-server) connection. This connection is the copilot's core: GROQ queries, schema inspection, drafts, releases, and media generation.

`import { connect } from "@vercel/connect/eve"; import { defineMcpClientConnection } from "eve/connections"; const sanityConnector = process.env.SANITY_CONNECTOR ?? "sanity/copilot-agent"; const APPROVAL_REQUIRED_TOOLS = ["patch_documents", "publish_documents", "unpublish_documents", "discard_drafts", "version_discard", "update_dataset", "deploy_schema", "deploy_studio", ]; export default defineMcpClientConnection({ approval: ({ toolName }) => APPROVAL_REQUIRED_TOOLS.some((tool) => toolName.includes(tool)) ? "user-approval" : "not-applicable", auth: connect(sanityConnector), description: "Sanity CMS: query documents with GROQ, inspect schemas, create/edit drafts, manage releases, generate media.", url: "https://mcp.sanity.io", });` Two details carry the weight here: - `connect()` makes it user-scoped: each person signs in once, the per-user token is resolved before every call, and the model never sees the URL or the token.    - The `approval` policy gates the destructive tools: any call whose name matches an entry in `APPROVAL_REQUIRED_TOOLS` pauses for an approve/deny decision, rendered as a Slack button, before it runs. Reads and draft creation flow freely; patching, publishing, and deploying wait for a human.    The model discovers the available Sanity tools at runtime through the built-in `connection__search` and calls them by their qualified name, such as `sanity__patch_documents`. The `description` field is the hint the model uses to decide when to reach for Sanity. ### Reading and writing Notion Notion follows the same pattern: `const APPROVAL_REQUIRED_TOOLS = [ "notion-update-pages", "notion-move-pages", "notion-update-data-source", "notion-update-view", ]; export default defineMcpClientConnection({ approval: ({ toolName }) => APPROVAL_REQUIRED_TOOLS.some((tool) => toolName.includes(tool)) ? "user-approval" : "not-applicable", auth: connect(notionConnector), description: "Notion workspace: search, read, and edit pages and databases.", url: "https://mcp.notion.com/mcp", });` Page updates, moves, and data-source changes are gated; page creation (`notion-create-pages`) is left ungated on purpose, because drafting a new page into Notion is the copilot's normal flow. To gate creation too, add it to the list. ### Sanity expertise as skills Seven skills sit under `agent/skills/`, each a folder with a `SKILL.md` and reference files. Six of them come from Sanity's [Agent Toolkit](https://github.com/sanity-io/agent-toolkit), and the `sanity-best-practices` references are the same canonical content behind the Sanity MCP server's rules tools:

- `sanity-best-practices`: schemas, GROQ, TypeGen, Visual Editing, Sanity Functions, Blueprints, and framework integrations from Next.js to Hydrogen.
  
- `content-modeling-best-practices`: designing and refactoring content types, references versus embedding, taxonomies.
  
- `portable-text-conversion` and `portable-text-serialization`: moving rich text into Portable Text and rendering it back out to React, HTML, Markdown, and other formats.
  
- `seo-aeo-best-practices`: metadata, structured data, EEAT, and more.
  
- `content-experimentation-best-practices`: A/B tests and variants.
  
- `writing-quality`: prose rules for anything written for humans, from AI-tells to avoid through web-content specs.
  

The `description` in each skill's 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 a 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. The instructions tell the agent to load the matching skill before acting, not after something goes wrong.

### Fresh-context subagents

Two subagents live under `agent/subagents/`, and each runs in its own fresh child session: it inherits none of the root's skills, connections, or tools, so the root packs everything the subagent needs into the call `message`, and the result comes back as a structured tool result.

The `researcher` handles outside facts. When a piece needs a statistic, a competitor detail, or a claim verified, the root delegates rather than reaching from memory. The researcher uses the framework's `web_search` and `web_fetch` tools and returns an `outputSchema`\-typed result: `findings` where every claim carries at least one real source URL and a confidence level, plus `gaps` for anything it couldn't verify. The root weaves in only cited findings and surfaces the gaps to you instead of papering over them.

The `reviewer` handles the final pass. On a finished draft, the root sends the full text plus any voice or audience context, and the reviewer judges it against the `writing-quality` rubric with fresh eyes, returning a `ready` or `revise` verdict with one entry per concrete issue: the severity, the rule broken, the offending quote, and a suggested fix. A reviewer that never saw the sources or the drafting reasoning catches the AI-tells and bloated wording that self-review reads right past.

Two implementation details make the reviewer self-sufficient: it carries its own copy of the `writing-quality` skill under `agent/subagents/reviewer/skills/`, and it declares its own `sandbox.ts`, because a subagent's sandbox doesn't inherit from the root and it needs one to read the skill's reference files.

### Remembering each user, agent/tools/\*\_user\_preferences.ts

Three tools give the copilot durable, per-user memory backed by Vercel Blob: `get_user_preferences`, `save_user_preferences`, and `clear_user_preferences`.

When a user states a standing preference ("our production dataset is `prod`", "always write titles in sentence case"), the agent loads the current document, merges the note in, and saves the full result, so the file stays curated rather than append-only.

The isolation model is the part worth reading:

``export const userPreferencesKey = (principal: UserPrincipal): string | null => { if (principal?.principalType !== "user" || !principal.principalId) { return null; } const id = createHash("sha256").update(principal.principalId).digest("hex"); return `${USER_PREFERENCES_PREFIX}${id}.md`; };``

The Blob key is derived entirely from the framework-resolved principal (`ctx.session.auth.current`), never from model input, so a session can only ever read or write its own user's file, and the principal id is hashed so the stored path carries no raw identifier. The general asset tools refuse the reserved `user-preferences/` prefix, so they can't be used as a side channel to another user's preferences. `clear_user_preferences` is irreversible and gated on approval.

### Storing assets in Vercel Blob

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

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({ approval: always(), description: "Permanently delete an asset from Vercel Blob storage by its URL. Use only when the user " + "explicitly asks to remove a stored file. This is irreversible.", inputSchema: z.object({ url: z.url().describe("The full Vercel Blob URL of the asset to delete."), }), async execute({ url }) { await del(url); return { deleted: true, success: true, url }; }, });`

`always()` from `eve/tools/approval` displays a card with approve and deny buttons in Slack before the deletion runs, so the model can never delete a file on its own.

### Behavior and model

`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: load preferences and the right skill, ground work in the real project with GROQ and schema reads, work in drafts and publish only on approval, draft long pieces into Notion, and get a fresh-eyes review before proposing a draft.

The rule worth calling out is the drafts contract: the agent never publishes, patches, or deploys speculatively, and the approval gates in the connections back that promise up at the tool layer.

The runtime configuration adds session management on top of the model:

`import { defineAgent } from "eve"; export default defineAgent({ compaction: { thresholdPercent: 0.75 }, limits: { maxInputTokensPerSession: 500_000, maxOutputTokensPerSession: 20_000, }, model: "anthropic/claude-sonnet-5", });`

Conversation history is compacted once it reaches 75% of the context window, and the per-session token limits cap runaway sessions; raise them if long content work hits the caps. 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](https://vercel.com/ai-gateway/models) works) or run `/model` in the dev TUI. Each subagent sets its own model in its `agent.ts`.

## Customize the copilot

The agent picks up changes to these files as you save:

1. Change the approval gates. Edit the `APPROVAL_REQUIRED_TOOLS` lists in `agent/connections/sanity.ts` and `agent/connections/notion.ts` to change which MCP tools pause for a human decision. For example, add `notion-create-pages` to gate Notion page creation too.
   
2. Change the behavior. Edit `agent/instructions.md`; it describes the whole workflow the agent follows.
   
3. Edit or add skills. Each folder under `agent/skills/` holds a `SKILL.md` (give it a `description` that names when to use it) plus reference files, and eve discovers new skills automatically. The reviewer keeps its own copy of `writing-quality` under `agent/subagents/reviewer/skills/`, so keep the two in step when you edit either.
   
4. Add or change tools. Files under `agent/tools/` are tools, and the filename is the tool name.
   

To add another chat platform, [add a channel file](https://eve.dev/docs/channels/overview) 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 workflow, skills, tools, and connections stay unchanged.

## Pair it with the content agent template

The [eve content agent template](https://github.com/vercel-labs/eve-content-agent-template) is a full content assistant: per-surface style skills, a house voice, and a style lint. Instead of merging all of that into this copilot, you can deploy it as its own agent and let the copilot delegate to it through eve's [remote agents](https://eve.dev/docs/guides/remote-agents) feature.

Deploy the content agent template as its own Vercel project, then add a remote subagent file to this repo. The filename is the tool name, and `vercelOidc()` handles deployment-to-deployment auth with no shared secret:

`import { defineRemoteAgent } from "eve"; import { vercelOidc } from "eve/agents/auth"; export default defineRemoteAgent({ url: () => process.env.CONTENT_AGENT_URL ?? "https://your-content-agent.vercel.app", description: "Drafts blog posts, LinkedIn and X posts, release notes, and newsletters in the house voice. " + "Pass the surface, the source material, and any constraints in the message.", auth: vercelOidc(), });`

Set `CONTENT_AGENT_URL` in this project's environment variables and mention the new subagent in `agent/instructions.md` so the copilot knows when to hand off.

The remote agent runs in its own deployment with its own skills and connections, and it never sees this copilot's conversation history, so the copilot packs everything the writer needs into the call `message`. The result comes back as a normal tool result, the same shape as the local `researcher` and `reviewer` subagents.

## 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, Sanity, or Notion.

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

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

### A Sanity edit pauses and nothing happens

**Symptom**: A patch, publish, or schema deploy appears to stall.

**Cause**: The call is gated on approval and is waiting for a decision. In Slack it renders as approve/deny buttons; in the dev TUI it renders as an approval prompt.

**Fix**: Approve or deny the pending call. To change which tools are gated, edit the `APPROVAL_REQUIRED_TOOLS` lists in `agent/connections/sanity.ts` and `agent/connections/notion.ts`.

### 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

- [Sanity copilot eve template](https://github.com/vercel-labs/sanity-copilot-eve-template)
  
- [eve documentation](https://eve.dev/docs)
  
- [Vercel Connect](https://vercel.com/docs/connect) and [Vercel Connect CLI](https://vercel.com/docs/cli/connect)
  
- [Sanity documentation](https://www.sanity.io/docs) and the [Sanity Agent Toolkit](https://github.com/sanity-io/agent-toolkit)
  
- [Vercel Blob](https://vercel.com/docs/vercel-blob)
  
- [Vercel Sandbox](https://vercel.com/docs/sandbox)
  
- [Vercel AI Gateway](https://vercel.com/docs/ai-gateway)
  
- [Vercel OIDC](https://vercel.com/docs/oidc)
  
- [eve content agent template](https://github.com/vercel-labs/eve-content-agent-template)

---

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