Example 05

Content review

A useful approval workflow that researches, drafts, pauses for a human, and publishes after approval.

PatternDurable steps + human-in-the-loop approval
Resulthttp://localhost:8787/review/demo
View source
Guided build

Build it from an empty Ayjnt project

Follow the steps in order. Every source block below is the actual checked example file, so the tutorial and runnable project cannot quietly drift apart.

01

Scaffold and install

Create an empty harness and add only the dependencies this example needs.

terminal
bunx ayjnt new review-harness --empty
cd review-harness
bun install

# delete agents/alive after adding the files below
02

Define the co-located workflow

The workflow declares only its parameter type. Durable steps fetch, extract, draft, validate, and report completion.

Create agents/review/workflow.ts with this source:

agents/review/workflow.ts
import { AgentWorkflow } from "ayjnt/workflows";
import type { AgentWorkflowEvent, AgentWorkflowStep } from "ayjnt/workflows";

type Params = { id: string; topic: string; sourceUrl: string };
export type ReviewWorkflowResult = {
  id: string;
  draft: string;
  checks: string[];
};

export default class ReviewWorkflow extends AgentWorkflow<Params> {
  override async run(
    event: Readonly<AgentWorkflowEvent<Params>>,
    step: AgentWorkflowStep,
  ): Promise<{ awaitingApproval: boolean }> {
    const { id, topic, sourceUrl } = event.payload;

    const source = await step.do("fetch-source", async () => {
      const response = await fetch(sourceUrl, {
        headers: { "user-agent": "ayjnt-workflow-example/1.0" },
      });
      if (!response.ok) throw new Error(`Source returned ${response.status}`);
      return (await response.text()).replace(/\s+/g, " ").slice(0, 5_000);
    });

    const research = await step.do("extract-research", async () => ({
      topic,
      excerpt: stripMarkup(source).slice(0, 700),
      sourceUrl,
    }));

    const draft = await step.do("draft-update", async () =>
      [
        `# ${research.topic}`,
        "",
        research.excerpt,
        "",
        `Source: ${research.sourceUrl}`,
      ].join("\n")
    );

    const checks = await step.do("policy-check", async () => {
      const results = [
        draft.length < 1_500 ? "Length is within the review limit." : "Draft is too long.",
        draft.includes(sourceUrl) ? "Source attribution is present." : "Source attribution is missing.",
        !/password|secret key/i.test(draft) ? "No obvious credential language found." : "Possible credential language found.",
      ];
      if (results.some((result) => result.includes("missing") || result.includes("too long"))) {
        throw new Error(results.join(" "));
      }
      return results;
    });

    await step.reportComplete<ReviewWorkflowResult>({ id, draft, checks });
    return { awaitingApproval: true };
  }
}

function stripMarkup(value: string) {
  return value
    .replace(/<script[\s\S]*?<\/script>/gi, " ")
    .replace(/<style[\s\S]*?<\/style>/gi, " ")
    .replace(/<[^>]+>/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}
03

Start it without a binding string

The agent calls this.workflow(params); generated types connect those params to the neighboring workflow.

Create agents/review/agent.ts with this source:

agents/review/agent.ts
import { Agent, callable } from "ayjnt";
import type { ReviewWorkflowResult } from "./workflow.ts";

export type ReviewItem = {
  id: string;
  topic: string;
  sourceUrl: string;
  status: "preparing" | "awaiting-approval" | "approved" | "rejected";
  draft?: string;
  checks?: string[];
  workflowId?: string;
  createdAt: number;
  decidedAt?: number;
};
type State = { items: ReviewItem[] };

export default class ReviewAgent extends Agent<State> {
  override initialState: State = { items: [] };

  @callable()
  async prepare(topic: string, sourceUrl: string): Promise<string> {
    const id = crypto.randomUUID();
    this.setState({
      items: [{
        id,
        topic: topic.trim(),
        sourceUrl: new URL(sourceUrl).toString(),
        status: "preparing",
        createdAt: Date.now(),
      }, ...this.state.items],
    });
    const workflowId = await this.workflow({ id, topic, sourceUrl });
    this.update(id, { workflowId });
    return id;
  }

  async draftReady(id: string, draft: string, checks: string[]): Promise<void> {
    this.update(id, { draft, checks, status: "awaiting-approval" });
  }

  override async onWorkflowComplete(
    _workflowName: string,
    _workflowId: string,
    result?: unknown,
  ): Promise<void> {
    const completed = result as ReviewWorkflowResult | undefined;
    if (!completed) return;
    await this.draftReady(completed.id, completed.draft, completed.checks);
  }

  @callable()
  async decide(id: string, decision: "approved" | "rejected"): Promise<void> {
    const item = this.state.items.find((candidate) => candidate.id === id);
    if (!item || item.status !== "awaiting-approval") {
      throw new Error("This item is not awaiting a decision.");
    }
    this.update(id, { status: decision, decidedAt: Date.now() });
  }

  private update(id: string, patch: Partial<ReviewItem>) {
    this.setState({
      items: this.state.items.map((item) =>
        item.id === id ? { ...item, ...patch } : item
      ),
    });
  }
}
04

Put the decision in front of a person

The UI surfaces the draft and checks, then records an explicit approval or rejection.

Create agents/review/app.tsx with this source:

agents/review/app.tsx
import { useState } from "react";
import { useAgent } from "@ayjnt/review";

export default function Review() {
  const agent = useAgent();
  const [topic, setTopic] = useState("Ayjnt project update");
  const [source, setSource] = useState("https://github.com/northclock/ayjnt");
  const state = agent.state;
  if (!state) return <main style={styles.main}>Connecting…</main>;

  const submit = async (event: React.FormEvent) => {
    event.preventDefault();
    await agent.call("prepare", [topic, source]);
  };

  return (
    <main style={styles.main}>
      <header style={styles.header}><div><small style={styles.eyebrow}>DURABLE WORKFLOW</small><h1 style={styles.h1}>Human review queue</h1></div><span>{state.items.filter((item) => item.status === "awaiting-approval").length} awaiting you</span></header>
      <form onSubmit={submit} style={styles.form}>
        <input value={topic} onChange={(event) => setTopic(event.target.value)} style={styles.input} required />
        <input value={source} onChange={(event) => setSource(event.target.value)} style={{ ...styles.input, flex: 1 }} required />
        <button style={styles.primary}>Prepare draft</button>
      </form>
      <section style={styles.queue}>
        {state.items.map((item) => (
          <article key={item.id} style={styles.card}>
            <div style={styles.cardHead}><div><strong>{item.topic}</strong><a href={item.sourceUrl}>{item.sourceUrl}</a></div><Status value={item.status} /></div>
            {item.draft && <pre style={styles.draft}>{item.draft}</pre>}
            {item.checks && <ul style={styles.checks}>{item.checks.map((check) => <li key={check}>✓ {check}</li>)}</ul>}
            {item.status === "awaiting-approval" && (
              <div style={styles.actions}>
                <button onClick={() => agent.call("decide", [item.id, "approved"])} style={styles.approve}>Approve</button>
                <button onClick={() => agent.call("decide", [item.id, "rejected"])} style={styles.reject}>Reject</button>
              </div>
            )}
          </article>
        ))}
      </section>
    </main>
  );
}

function Status({ value }: { value: string }) {
  return <span style={{ ...styles.status, background: value === "approved" ? "#d9f4e9" : value === "rejected" ? "#fbe0dc" : value === "awaiting-approval" ? "#fff0bf" : "#e5ebf6" }}>{value}</span>;
}

const styles = {
  main: { fontFamily: "system-ui, sans-serif", maxWidth: 900, margin: "0 auto", padding: "42px 22px", color: "#172033" },
  header: { display: "flex", justifyContent: "space-between", alignItems: "end", paddingBottom: 22, borderBottom: "1px solid #dfe2e7" },
  eyebrow: { color: "#225dd8", fontFamily: "monospace", letterSpacing: "0.14em" },
  h1: { margin: "6px 0 0", fontSize: 38 },
  form: { display: "flex", gap: 8, padding: "22px 0" },
  input: { padding: 10, border: "1px solid #d2d7de", borderRadius: 8 },
  primary: { border: 0, borderRadius: 8, background: "#225dd8", color: "white", padding: "0 16px" },
  queue: { display: "grid", gap: 14 },
  card: { border: "1px solid #dfe2e7", borderRadius: 12, padding: 18, background: "#fbfbf8" },
  cardHead: { display: "flex", justifyContent: "space-between", gap: 20 },
  status: { padding: "6px 8px", borderRadius: 99, fontFamily: "monospace", fontSize: 9 },
  draft: { whiteSpace: "pre-wrap" as const, lineHeight: 1.6, background: "#f0f1ed", borderRadius: 8, padding: 15, fontFamily: "system-ui", fontSize: 13 },
  checks: { listStyle: "none", padding: 0, color: "#46765f", fontSize: 12 },
  actions: { display: "flex", gap: 8 },
  approve: { border: 0, borderRadius: 7, padding: "9px 14px", background: "#55c99d" },
  reject: { border: "1px solid #e18479", borderRadius: 7, padding: "9px 14px", background: "transparent", color: "#b94b40" },
};
05

Run the harness

terminal
bun run build
bun run dev

# open http://localhost:8787/review/demo

The build step generates bindings, migrations, client hooks, and environment types before the runtime starts.

Content review running in the browser
Expected browser result after completing the tutorial.

Project shape

agents/review/agent.tsagents/review/workflow.tsagents/review/app.tsx

Verify your result

  1. Submit a topic and reachable source URL.
  2. Watch the item move from preparing to awaiting approval.
  3. Review the draft and checks, then approve or reject it.

← Back to all examples