Skip to content
Docs

Draft documentation fixes from reader feedback with Sanity and eve

Build an autonomous agent that drafts fixes in Sanity, stages them for human review, and calls the model through AI Gateway.

10 min read
Last updated July 1, 2026

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, a filesystem-first framework for durable backend agents from Vercel. Sanity 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, 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.

eve Sanity Docs Agent

Automatically draft documentation fixes in Sanity and notify editors in Slack for review and approval before publishing.

See the Agent

Before you begin, make sure you have:

  • Node.js 24+ and a package manager (e.g., pnpm)
  • A Vercel account
  • Vercel CLI installed (npm i -g vercel)
  • A Slack workspace where you can install an app

For Sanity, you’ll also need:

  • A Sanity 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)
Terminal
git clone https://github.com/sanity-labs/sanity-eve-docs-agent
cd sanity-eve-docs-agent
npm install

The repo 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 to your coding agent (e.g., Claude Code), point it at this guide, and ask it to create a project with the content model below and create some example documentation.

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

article.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
],
})
feedback.ts
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.
  • 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:

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

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

Terminal
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, or run:

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

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

Terminal
cp .env.example .env.local
.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:

Terminal
npx sanity tokens add

AI Gateway and Vercel Connect authenticate requests using Vercel OIDC tokens, which Vercel automatically generates and links to your project.

First, link your local project to a Vercel project:

Terminal
vercel link

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

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

Start the dev server:

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

Terminal
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>
Terminal
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
agent/channels/eve.ts
export default eveChannel({
auth: [triggerSecret(), localDev(), vercelOidc()],
});

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:

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

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.

Two files carry most of the design: the tool that writes the edit and the guardrails that keep the agent's reach small.

The agent doesn't hand-write Portable Text.

Instead, it composes a precise instruction, and stage_article_edit runs a Sanity Agent Action (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.

agent/tools/stage_article_edit.t
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").

This agent is triggered by untrusted reader input, so its capability surface is deliberately small. The default eve 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:

tools/bash.ts
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.
  • 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 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 so that a week of fixes is reviewed as a single set.
  • More surfaces: Add other eve channels (e.g., Microsoft Teams) for notices, or adopt eve's conversational slackChannel (the same Slack connector) for interactive buttons and threaded replies.

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.

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.

Was this helpful?

supported.