npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

content-agent

v1.1.0

Published

Vercel AI SDK provider for Sanity Content Agent API

Readme

content-agent

Vercel AI SDK provider for Sanity Content Agent API.

Installation

npm install content-agent ai

Usage

ContentAgent extends the AI SDK's ToolLoopAgent — it inherits .generate(), .stream(), telemetry, and every other capability of the AI SDK's first-class agent abstraction. Construction is the only thing that differs: pass a provider and threadId instead of a model, and ContentAgent resolves the model for you.

import { createContentAgent } from "content-agent";
import { ContentAgent } from "content-agent/agent";

const provider = createContentAgent({
  organizationId: "your-org-id",
  token: "your-sanity-token",
});

const agent = new ContentAgent({
  provider,
  threadId: "my-thread",
});

const { text } = await agent.generate({
  prompt: "What blog posts do I have?",
});

provider is reusable — create it once per organization and instantiate as many ContentAgents as you need.

Why a class instead of streamText({ model, ... })?

The AI SDK 6 explicitly invites SDK authors to ship their own Agent implementations: "The Agent is now an interface instead of a class, which allows developers to implement their own agent abstractions for specific needs." ContentAgent is that implementation for the Sanity Content Agent — it preconfigures stopWhen, prepareStep, and toolChoice for the final_answer reply pipeline so you don't have to.

Migrating from streamText({ model: provider.agent(threadId) })? Nothing breaks — the lower-level API is still supported and works exactly as before. Switch to ContentAgent to pick up the safe defaults that prevent empty responses when the model burns its step budget on client tool calls. See How replies work below.

Streaming

const result = await agent.stream({
  prompt: "Summarize my latest content",
});

for await (const text of result.textStream) {
  process.stdout.write(text);
}

Tools

Pass AI SDK tools to the constructor — schemas are forwarded to the agent, and execution happens locally on the client:

import { ContentAgent } from "content-agent/agent";
import { tool } from "ai";
import { z } from "zod";

const agent = new ContentAgent({
  provider,
  threadId: "my-thread",
  tools: {
    submit_for_review: tool({
      description: "Submit a document for editorial review.",
      inputSchema: z.object({
        documentId: z.string().describe("The Sanity document _id"),
        email: z.string().email().describe("Reviewer email"),
        note: z.string().optional().describe("Note for the reviewer"),
      }),
      execute: async ({ documentId, email, note }) => {
        const res = await fetch("https://workflows.example.com/api/review", {
          method: "POST",
          body: JSON.stringify({ documentId, email, note }),
        });
        return res.json();
      },
    }),
  },
});

const result = await agent.stream({
  prompt: "Find all draft blog posts and submit them for review.",
});

How replies work

Every reply is delivered through a server-side final_answer tool call. The agent streams final_answer.message to the wire as text-delta chunks (parsed incrementally from the tool-call args) and includes structured suggestions for clients that render reply buttons. There's no separate "streaming mode" — every conversation, every client follows the same path.

ContentAgent adjusts the loop control primitives to make this safe by default:

  1. stopWhen defaults to "stop on the first step that contains a final_answer tool call," plus a stepCountIs(20) safety cap. A final_answer accompanied by other tool calls in the same step is the canonical act-and-answer pattern — the loop terminates without an extra round-trip.
  2. prepareStep is composed with a safety net that, on the cap step, forces toolChoice { type: 'tool', toolName: 'final_answer' } if the model has been calling tools without ever converging on final_answer. This guarantees a reply on the wire even in pathological cases where the model would otherwise burn its step budget on client-tool round-trips.
  3. Server-side recovery (SAGE-248) — if the model fails to call final_answer naturally on any single turn, the agent runner retries that turn with forced tool_choice. Anthropic's API enforces forced tool choice strictly; the recovery is guaranteed to produce a final_answer chunk.

If you pass your own stopWhen or prepareStep, they take precedence — ContentAgent doesn't reach into them.

Applications

Each application key uniquely identifies a deployed Sanity Studio workspace. Since multiple studios can share the same project ID and dataset, the application key disambiguates which one to target.

List available applications for your organization, then target one by its key:

const apps = await provider.applications();
const app = apps.find((app) => app.title === "My Studio");

const agent = new ContentAgent({
  provider,
  threadId: "my-thread",
  application: { key: app.key },
});

You can also set a default application on the provider so every agent and .prompt() call inherits it:

const provider = createContentAgent({
  organizationId: "your-org-id",
  token: "your-token",
  application: resolvedApp,
});

Configuration

Pass a config object to control agent behavior:

const agent = new ContentAgent({
  provider,
  threadId: "my-thread",
  config: {
    instruction: "You are a helpful content assistant for our marketing team.",
    userMessageContext: {
      "slack-channel": "#marketing",
      "user-role": "editor",
    },
    capabilities: {
      read: true,
      write: false,
    },
    filter: {
      read: '_type in ["post", "author", "category"]',
      write: '_type == "post"',
    },
  },
});

Config fields

| Field | Type | Description | | -------------------- | ------------------------ | ---------------------------------------------------------------- | | instruction | string | Custom instruction included in the system prompt | | userMessageContext | Record<string, string> | Key-value pairs appended to each user message as XML tags | | capabilities | object | Controls what operations the agent can perform (read, write) | | filter | object | GROQ boolean expressions for document access control | | perspectives | object | Perspective locking for read and write operations |

Capabilities

Each capability can be true, false, or a preset object:

// Read-only
config: { capabilities: { read: true, write: false } }

// Minimal write access
config: { capabilities: { read: true, write: { preset: "minimal" } } }

One-shot prompts

For stateless single-turn requests, use the provider's .prompt() method directly — no thread, no agent class:

const { text } = await provider.prompt(
  {
    application: { key: "projectId.datasetName" },
    config: { capabilities: { read: true, write: false } },
    instructions: "Be concise",
  },
  { message: "List my 5 most recent posts" },
);

Studio usage

createStudioAgent makes the provider work inside Sanity Studio with no token or manual configuration. It derives the API host, organization ID, and application from the Studio context:

import { useMemo } from "react";
import { createStudioAgent } from "content-agent";
import { ContentAgent } from "content-agent/agent";
import { useClient, useWorkspace } from "sanity";

function MyComponent() {
  const client = useClient({ apiVersion: "2024-01-01" });
  const { name: workspace } = useWorkspace();

  const provider = useMemo(
    () => createStudioAgent(client, workspace),
    [client, workspace],
  );

  const agent = useMemo(
    () => new ContentAgent({ provider, threadId: "my-thread" }),
    [provider],
  );

  // Use agent.stream() / agent.generate() with useChat, etc.
}

The factory call is synchronous — async setup (org ID fetch, application resolution) runs lazily on the first API call and is cached at the module level. Wrap in useMemo so the provider and agent keep stable identities across renders.

Since there is no token in a Studio context, credentials: 'include' is set on all fetch calls automatically so the browser sends session cookies. The base URL is derived from the client's project-scoped getUrl method.

Using with useChat

For a streaming chat UI, pair with DirectChatTransport and useChat from the AI SDK:

import { useMemo, useState } from "react";
import { useClient, useWorkspace } from "sanity";
import { useChat } from "@ai-sdk/react";
import { DirectChatTransport } from "ai";
import { createStudioAgent } from "content-agent";
import { ContentAgent } from "content-agent/agent";

function ChatTool() {
  const client = useClient({ apiVersion: "2024-01-01" });
  const { name: workspace } = useWorkspace();
  const [threadId] = useState(() => `thread-${crypto.randomUUID()}`);

  const transport = useMemo(() => {
    const provider = createStudioAgent(client, workspace);
    const agent = new ContentAgent({ provider, threadId });
    return new DirectChatTransport({ agent });
  }, [client, workspace, threadId]);

  const { messages, sendMessage, status } = useChat({ transport });

  // Render messages, input form, etc.
}

Lower-level helpers

If you need more control than ContentAgent provides, the underlying primitives are still exported:

  • provider.agent(threadId, settings) — returns a raw LanguageModelV3 you can pass to streamText/generateText/ToolLoopAgent directly
  • fromClient(client, workspace) — derives baseURL and application from a Sanity client
  • provider.resolveApplication(client, workspace) — fetches applications and matches by resource ID + workspace name

License

MIT