# Draft documentation fixes from reader feedback with Sanity and eve

**Author:** Knut Melvær, Ben Sabic

---

Reader feedback on documentation piles up faster than anyone can triage it. Someone flags a missing step, the note lands in a queue, and weeks later, the page still has the gap. This guide builds an agent that closes that loop: when a reader leaves feedback on a docs article, the agent reads the article, drafts a fix, stages it as an unpublished draft, and posts a Slack notice with a button to review it in Sanity Studio. A human always approves before anything goes live.

The bot is built on [eve](https://beta.eve.dev/), a filesystem-first framework for durable backend agents from Vercel. [Sanity Functions](https://www.sanity.io/docs/functions) trigger it on every new piece of feedback, and a weekly cron runs as a backstop for anything the event path misses. Slack notices are delivered through [Vercel Connect](https://vercel.com/docs/connect), so there is no bot token to manage.

This pattern also applies to other content operations. Anywhere an agent reacts to a trigger and stages a content update for review, the same shape applies to broken-link sweeps, freshness audits, or metadata backfills.

Fork the agent, customize it, and read on to see how it all works.

## Setup and deployment

### Prerequisites

Before you begin, make sure you have:

- Node.js 24+ and a package manager (e.g., [pnpm](https://pnpm.io/))
  
- A [Vercel account](https://vercel.com/signup)
  
- Vercel CLI installed (`npm i -g vercel`)
  
- A Slack workspace where you can install an app
  

For Sanity, you’ll also need:

- A [Sanity](https://www.sanity.io/) project with a deployed Studio, an `article` type (with a Portable Text body), and a `feedback` type.
  
- A Sanity API token with the **Editor** role to read content and write drafts.
  
- Sanity CLI installed (`npm i -g sanity`)
  

### Clone the template and install the dependencies

`git clone https://github.com/sanity-labs/sanity-eve-docs-agent cd sanity-eve-docs-agent npm install`

[The repo](https://github.com/sanity-labs/sanity-eve-docs-agent) is a complete eve agent:

- Tools live in `agent/tools/`
  
- The operating policy in `agent/instructions.md`
  
- The model in `agent/agent.ts`
  
- The Slack channel and auth in `agent/channels/eve.ts`
  
- The Sanity Function trigger under `sanity/`
  

This guide assumes you already have a Sanity project.

If you don't and want to try it anyway, add the [Sanity MCP server](https://www.sanity.io/docs/ai/mcp-server) to your coding agent (e.g., [Claude Code](https://code.claude.com/docs/en/mcp)), point it at this guide, and ask it to create a project with the content model below and create some example documentation.

### Add the content model

The agent works against two document types, `article` and `feedback`, which you'll need to add to your Studio's schema.

In a typical layout, that means creating `article.ts` and `feedback.ts` under your schema folder (e.g. `sanity/schemaTypes/`) and registering both in your schema index (e.g. `schemaTypes/index.ts`).

`import { defineType, defineField } from 'sanity' export const articleType = defineType({ name: "article", type: "document", fields: [defineField({ name: "title", type: "string" }), defineField({ name: "content", type: "array", of: [{ type: "block" }] }), // Portable Text ], })` `import { defineType, defineField } from 'sanity' export const feedbackType = defineType({ name: "feedback", type: "document", fields: [ defineField({ name: "article", type: "reference", to: [{ type: "article" }], weak: true }), defineField({ name: "comment", type: "text" }), defineField({ name: "rating", type: "number" }), // optional // Written by the agent (readOnly in the form; the API token still writes them): defineField({ name: "handledAt", type: "datetime", readOnly: true }), // set = handled defineField({ name: "outcome", type: "string", readOnly: true }), // "edited" | "skipped" defineField({ name: "outcomeNote", type: "string", readOnly: true }), // why, when skipped ], })` How these types are used: - The read tools query them with [GROQ](https://www.sanity.io/docs/content-lake/groq-introduction).
  
- `stage_article_edit` applies a single patch to the `content` field.
  

If you rename a field or type, update the references to match:

- The GROQ in `agent/tools/read_feedback.ts` and `agent/tools/read_article.ts`
  
- The patch target in `agent/tools/stage_article_edit.ts`
  

Deploy the Studio so the schema is live:

`npx sanity deploy`

After deploying, grab two values and set them as environment variables:

- **Schema ID:** get it with `npx sanity schema list`, then set it as `SANITY_SCHEMA_ID`. Agent Actions use this.
  
- **Studio App ID:** `sanity deploy` writes this into `sanity.cli.ts`. Set it as `SANITY_STUDIO_APP_ID`, it’ll be used for the Slack review button link.
  

### Configure Slack with Vercel Connect

To send Slack notices, your agent needs Slack bot credentials.

Bundling long-lived API keys into your deployment is risky: the secret sits in your environment indefinitely, applies to every request, and is hard to scope or revoke.

[Vercel Connect](https://vercel.com/kb/guide/vercel-connect) solves this by issuing short-lived provider tokens at runtime instead. You register a connector for a provider once, link it to your projects and environments, and your code requests a scoped token only when it needs one.

To create a Slack connector, run the following command:

`export FF_CONNECT_ENABLED=1 vercel connect create slack --name docs-sanity # walks through installing the Slack app`

Set the connector UID it prints as the `SLACK_CONNECTOR` environment variable.

Attach the connector to your Vercel project in the [Vercel dashboard](https://vercel.com/d?to=%2F%5Bteam%5D%2F~%2Fconnect), or run:

`vercel connect attach slack/docs-sanity`

Invite the agent to the Slack channel and set the channel ID as the `SLACK_CHANNEL` environment variable. That's the channel the agent posts its review notices to.

### Configure environment variables

Copy the `.env.example` file, then fill in the values that connect the agent to your Sanity project, Slack, and a model:

`cp .env.example .env.local`

``SANITY_STUDIO_PROJECT_ID=your-project-id SANITY_STUDIO_DATASET=production SANITY_API_WRITE_TOKEN=your-editor-token SANITY_SCHEMA_ID=sanity.workspace.schema.default # from `npx sanity schema list` (for Agent Actions) SANITY_ORG_ID=oSyH1iET5 # review button: org id + app id (both in sanity.cli.ts) SANITY_STUDIO_APP_ID=your-studio-app-id # the studio app id, added on `sanity deploy` SLACK_CONNECTOR=slack/docs-sanity # Slack app via Vercel Connect (configured at deploy) SLACK_CHANNEL=C0123456789 # channel to post to (invite the app to it) EVE_TRIGGER_SECRET=a-long-random-string # the Function authenticates with this``

Name the token after the agent, for example **docs-feedback agent**.

Sanity's document history then attributes every staged draft to that identity, so provenance is clear without marking the content itself.

You can create tokens with the Sanity CLI:

`npx sanity tokens add`

AI Gateway and Vercel Connect authenticate requests using [Vercel OIDC tokens](https://vercel.com/docs/oidc), which Vercel automatically generates and links to your project.

First, link your local project to a Vercel project:

`vercel link`

Then pull your environment variables, which include the OIDC token:

`vercel env pull .env.local`

This writes the token to your `.env.local` file. OIDC tokens are valid for 12 hours, so during local development, you'll need to run `vercel env pull` again to refresh the token when it expires.

When you deploy to Vercel, OIDC tokens are provisioned automatically, so no further setup is required for production or preview deployments.

### Run it locally

Start the dev server:

`npm run dev`

This opens the eve TUI, where you can drive the agent by hand and watch tool calls.

**Option 1: Feed the agent a feedback document directly**

In Studio, create a `feedback` document that references an article. Then give the agent its `_id` in the TUI:

**New reader feedback to handle. Feedback document \_id: "<feedback-id>".**

You'll see the agent work through its sequence: `read_feedback`, `read_article`, `stage_article_edit`, `mark_feedback_handled`, and finally `post_to_slack`. Open the draft from the Slack button to review it.

One note: only the Slack step needs Vercel Connect. Draft staging and feedback-handling work without it, so you can test the core loop even if Slack isn't wired up yet.

**Option 2: Test the real trigger path**

To exercise the path that runs in production, where the Sanity Function fires the agent, use Sanity's local function testing. Point `EVE_AGENT_URL` at the address eve dev prints on boot (`http://127.0.0.1:2000` by default):

`cd sanity EVE_AGENT_URL=http://127.0.0.1:2000 npx sanity@latest functions test on-feedback \ --document-id <a feedback _id> --project-id <projectId> --dataset <dataset>`

### Deploy to Vercel

`npx eve link npx eve deploy`

Set the same environment variables in the Vercel project, including a strong secret for the `EVE_TRIGGER_SECRET` environment variable.

Authentication is already wired up for you.

The verifier in `agent/channels/eve.ts` handles three cases:

- Accepts requests with `Authorization: Bearer ${EVE_TRIGGER_SECRET}`
  
- Falls through to local and Vercel-internal callers
  
- Returns `401` to everyone else
  

`export default eveChannel({ auth: [triggerSecret(), localDev(), vercelOidc()], });` ### Wire the Sanity Function trigger Deploy the Function, then give it the agent's URL and the shared secret. Function environment variables are set per function, after the first deploy: `cd sanity npx sanity@latest blueprints deploy npx sanity functions env add on-feedback EVE_AGENT_URL https://your-agent.vercel.app npx sanity functions env add on-feedback EVE_TRIGGER_SECRET "the same secret as the agent"` New feedback now fires the agent. Watch runs with `npx sanity functions logs on-feedback`, and check each run in eve's dashboard or your Vercel logs. ## How the agent works The agent runs the same short sequence whether an event or the weekly cron starts it: 1. **Readers leave feedback.** A new `feedback` document is created in Sanity, referencing the article it's about.     2. **The trigger starts the agent.** The Sanity Function fires on create and passes the agent only the feedback `_id`. The weekly cron in `agent/schedules/weekly-feedback-sweep.ts` is the backstop that sweeps up anything the event path missed.     3. **The agent reads the feedback and the article.** `read_feedback` loads the comment, and `read_article` loads the referenced article's body.     4. **It stages a draft edit.** `stage_article_edit` runs a schema-aware Sanity Agent Action that revises the body and writes the result to a draft.     5. **It marks the feedback handled.** `mark_feedback_handled` sets `handledAt` so a Function retry or the weekly sweep never processes the same item twice.     6. **It posts to Slack.** `post_to_slack` sends a notice with a review button that opens the draft in Sanity Studio.     7. **Editors review and publish changes.** Nothing goes live until a human approves it.     Sanity owns the content and triggers the agent when feedback arrives. eve owns the durable runs, orchestrating which tools the agent can use and where notices go. Because every change lands as a draft and every handled item is marked, the loop is safe to retry and safe to run on a schedule. ## Code walkthrough Two files carry most of the design: the tool that writes the edit and the guardrails that keep the agent's reach small. ### Staging edits as a schema-aware Agent Action The agent doesn't hand-write Portable Text. Instead, it composes a precise instruction, and `stage_article_edit` runs a Sanity [Agent Action](https://www.sanity.io/docs/agent-actions/transform-quickstart) (transform) that revises the article's body for it. Agent Actions are schema-aware, so the edit is valid content in the right place. By default, they never mutate a published document: pass the published `_id` and the action writes to the draft, either creating it from the published version or reusing an existing draft.

``await client.withConfig({ apiVersion: "vX" }).agent.action.transform({ schemaId: process.env.SANITY_SCHEMA_ID, // from `npx sanity schema list` documentId: articleId, // published id → writes to the draft, never publishes instruction, // the scoped instruction the agent composed target: [{ path: "content" }], // only the article body, not title/slug });`` The exact document gets the edit, the change lands as a draft for a human to review, and the model can't write to the wrong document or publish on its own. Agent Actions are an experimental API (use `apiVersion: "vX"`). ### Locking the agent down This agent is triggered by untrusted reader input, so its capability surface is deliberately small. The [default eve harness](https://eve.dev/docs/concepts/default-harness) includes shell, filesystem, and network tools (`bash`, `read_file`, `write_file`, `glob`, `grep`, `web_fetch`, `web_search`).

While they operate in a secure sandbox, you can still disable the ones the agent doesn't need by exporting a sentinel from a file named after each tool:

`import { disableTool } from "eve/tools"; export default disableTool();`

The agent is then left with five main capabilities, controlled via your own authored tools:

- Read feedback
  
- Read an article
  
- Stage a draft
  
- Mark feedback as handled
  
- Post the card to Slack.
  

With no publish or destructive tools, the worst a malicious comment can do is produce a draft for a human to review.

Two more guardrails make the agent more robust:

- The function passes only the feedback `_id`, so the agent reads the comment itself rather than trusting a payload.
  
- The `instructions.md` tells the agent to treat the comment as data describing a problem, never as instructions to follow.
  

## Extend the agent

- **Richer context:** Let the agent reason over related content, cheapest first: GROQ references (`*[references($id)]`), then keyword ranking with `score()` and `text::match`, then [Sanity Context](https://www.sanity.io/docs/ai/sanity-context) for schema-aware semantic search. See the comment in `agent/tools/read_article.ts`.
  
- **Fact-checking:** Restore the default `web_search` tool so the agent can verify claims on the web before drafting. It's off by default so unverified facts aren’t used.
  
- **Batch review:** On an Enterprise plan, stage fixes into a [Content Release](https://www.sanity.io/docs/content-releases) so that a week of fixes is reviewed as a single set.
  
- **More surfaces:** Add other [eve channels](https://eve.dev/integrations) (e.g., Microsoft Teams) for notices, or adopt eve's conversational `slackChannel` (the same Slack connector) for interactive buttons and threaded replies.
  

## Troubleshooting

### The agent doesn't run when feedback is created

**Symptom**: You create a `feedback` document, but no run appears in eve's dashboard or your Vercel logs.

**Cause**: The trigger was rejected (a wrong or missing `EVE_TRIGGER_SECRET`), or the agent was unreachable, or the Function's environment variables aren't set.

**Fix**: Confirm `EVE_AGENT_URL` and `EVE_TRIGGER_SECRET` are set on the Function and that the secret matches the agent's. When a trigger is rejected, the Function throws so Sanity retries rather than failing silently. Inspect the attempts with `npx sanity functions logs on-feedback`.

### The same feedback gets processed twice

**Symptom**: A Function retry or the weekly sweep re-edits an article you already handled.

**Cause**: The feedback document wasn't marked handled.

**Fix**: `mark_feedback_handled` sets `handledAt` on each item, and both the event path and the weekly sweep skip anything with `handledAt` set. Confirm the token in `SANITY_API_WRITE_TOKEN` has the **Editor** role so it can write that field.

## Related resources

- [eve documentation](https://eve.dev/docs)
  
- [Vercel Connect](https://vercel.com/docs/connect)
  
- [Sanity Functions](https://www.sanity.io/docs/functions)
  
- [Sanity Agent Actions: Transform quick start](https://www.sanity.io/docs/agent-actions/transform-quickstart)
  
- [Sanity Context](https://www.sanity.io/docs/ai/sanity-context)

---

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