# Introducing Experimental Workflows and Orchestrators in TanStack AI

> Source: <https://tanstack.com/blog/tanstack-ai-orchestration>
> Published: 2026-05-28 12:00:00+00:00

*by Alem Tuzlak on May 28, 2026.*

Most AI apps start with one chat() call.

But as soon as you need something more complex, this all breaks apart. You either fall back to using sub-agents as tools, or you have to write your own glue and abstractions on top to make a semi-decent workflow or orchestration mechanism to power your app. This just detracts from your time to work on the features you really care about.

The model needs to draft, critique, revise, ask for approval, call another model, update state, and show the user what is happening while the run is still in progress. At that point, a one-shot chat endpoint turns into a hand-rolled workflow engine made of fetch calls, temporary state, custom SSE events, and a lot of code nobody wanted to own.

Today, TanStack introduces an experimental answer: **TanStack AI Workflows & Orchestrators**.

Before we go further, a fair warning!

This is not merged to main. It is not shipped, stable, or available in normal npm versions. It is a PR build you can try today through pkg.pr.new while the API is still being shaped. The goal is to get it in front of real use cases, demos, and feedback before we commit to the public API shape.

This is where you come in. We need your help. We need people to test out our workflows and our orchestration mechanisms, give us their thoughts and opinions, and help us shape the final APIs.

The goal is simple: compose multiple typed LLM and agent steps as normal TypeScript async generators, stream each step to the UI, pause for human approval, and resume through the same SSE flow.

Install the PR packages directly from pkg.pr.new:

```
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-orchestration@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-client@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openai@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-orchestration@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-client@542
npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openai@542
```

Use this only for evaluation, demos, and feedback. The public API can still change before stabilization.

Full documentation for this PR lives on the GitHub branch, not the released TanStack docs site:

Agents are typed wrappers around a chat() call or any async function. defineAgent gives each step an input schema, output schema, and implementation.

``` js
import { chat } from '@tanstack/ai'
import { defineAgent } from '@tanstack/ai-orchestration'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'

export const ArticleSchema = z.object({
  title: z.string(),
  body: z.string(),
})

export const writer = defineAgent({
  name: 'writer',
  input: z.object({
    topic: z.string(),
  }),
  output: ArticleSchema,
  run: ({ input }) =>
    chat({
      adapter: openaiText('gpt-4o'),
      outputSchema: ArticleSchema,
      stream: true,
      systemPrompts: [
        'Write a concise developer article with a clear title and practical body.',
      ],
      messages: [{ role: 'user', content: input.topic }],
    }),
})

export const editor = defineAgent({
  name: 'editor',
  input: z.object({
    article: ArticleSchema,
    feedback: z.string().optional(),
  }),
  output: z.object({
    approved: z.boolean(),
    article: ArticleSchema,
    notes: z.string(),
  }),
  run: ({ input }) =>
    chat({
      adapter: openaiText('gpt-4o'),
      outputSchema: z.object({
        approved: z.boolean(),
        article: ArticleSchema,
        notes: z.string(),
      }),
      stream: true,
      systemPrompts: [
        'Edit the article for accuracy, clarity, and practical developer tone. Apply any optional reviewer feedback.',
      ],
      messages: [{ role: 'user', content: JSON.stringify(input) }],
    }),
})
js
import { chat } from '@tanstack/ai'
import { defineAgent } from '@tanstack/ai-orchestration'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'

export const ArticleSchema = z.object({
  title: z.string(),
  body: z.string(),
})

export const writer = defineAgent({
  name: 'writer',
  input: z.object({
    topic: z.string(),
  }),
  output: ArticleSchema,
  run: ({ input }) =>
    chat({
      adapter: openaiText('gpt-4o'),
      outputSchema: ArticleSchema,
      stream: true,
      systemPrompts: [
        'Write a concise developer article with a clear title and practical body.',
      ],
      messages: [{ role: 'user', content: input.topic }],
    }),
})

export const editor = defineAgent({
  name: 'editor',
  input: z.object({
    article: ArticleSchema,
    feedback: z.string().optional(),
  }),
  output: z.object({
    approved: z.boolean(),
    article: ArticleSchema,
    notes: z.string(),
  }),
  run: ({ input }) =>
    chat({
      adapter: openaiText('gpt-4o'),
      outputSchema: z.object({
        approved: z.boolean(),
        article: ArticleSchema,
        notes: z.string(),
      }),
      stream: true,
      systemPrompts: [
        'Edit the article for accuracy, clarity, and practical developer tone. Apply any optional reviewer feedback.',
      ],
      messages: [{ role: 'user', content: JSON.stringify(input) }],
    }),
})
```

defineAgent wraps either a chat() call or a normal async function with input and output schemas. The workflow runtime uses those schemas to validate what enters and leaves the step, and TypeScript uses them to infer the callable shape inside a workflow. From the workflow's perspective, writer and editor are normal typed async steps.

Once agents exist, defineWorkflow composes them with yield* inside an async function*.

``` js
import {
  approve,
  defineWorkflow,
  fail,
  succeed,
} from '@tanstack/ai-orchestration'
import { z } from 'zod'
import { ArticleSchema, editor, writer } from './agents'

const ArticleInputSchema = z.object({
  topic: z.string(),
})

const ArticleWorkflowOutputSchema = z.discriminatedUnion('ok', [
  z.object({
    ok: z.literal(true),
    article: ArticleSchema,
  }),
  z.object({
    ok: z.literal(false),
    reason: z.string(),
  }),
])

export const articleWorkflow = defineWorkflow({
  name: 'article-workflow',
  input: ArticleInputSchema,
  output: ArticleWorkflowOutputSchema,
  agents: {
    writer,
    editor,
  },
  run: async function* ({ input, agents }) {
    const draft = yield* agents.writer({ topic: input.topic })
    const edited = yield* agents.editor({ article: draft })

    if (!edited.approved) {
      return fail(edited.notes)
    }

    const decision = yield* approve({
      title: `Publish "${edited.article.title}"?`,
      description: edited.notes,
    })

    if (!decision.approved) {
      return fail(decision.feedback ?? 'Publication declined')
    }

    return succeed({ article: edited.article })
  },
})
js
import {
  approve,
  defineWorkflow,
  fail,
  succeed,
} from '@tanstack/ai-orchestration'
import { z } from 'zod'
import { ArticleSchema, editor, writer } from './agents'

const ArticleInputSchema = z.object({
  topic: z.string(),
})

const ArticleWorkflowOutputSchema = z.discriminatedUnion('ok', [
  z.object({
    ok: z.literal(true),
    article: ArticleSchema,
  }),
  z.object({
    ok: z.literal(false),
    reason: z.string(),
  }),
])

export const articleWorkflow = defineWorkflow({
  name: 'article-workflow',
  input: ArticleInputSchema,
  output: ArticleWorkflowOutputSchema,
  agents: {
    writer,
    editor,
  },
  run: async function* ({ input, agents }) {
    const draft = yield* agents.writer({ topic: input.topic })
    const edited = yield* agents.editor({ article: draft })

    if (!edited.approved) {
      return fail(edited.notes)
    }

    const decision = yield* approve({
      title: `Publish "${edited.article.title}"?`,
      description: edited.notes,
    })

    if (!decision.approved) {
      return fail(decision.feedback ?? 'Publication declined')
    }

    return succeed({ article: edited.article })
  },
})
```

The interesting part is the lack of framework ceremony. TypeScript knows the input expected by agents.writer, and it knows the shape returned after the yield*. The runtime can emit lifecycle events around each yielded step, stream text while it runs, validate the result, snapshot state, and resume the generator with the typed output.

Why async generator workflows? There are a lot of ways to model agent workflows. You can build a graph DSL. You can define nodes in JSON. You can describe a DAG and ask the runtime to interpret it. Well, the reason we decided to go with generator workflows is because whenever you yield the agent's step, it's streamed straight down to the client. The user sees everything in real time, and then, by the end of it, you just get the final output back. The user saw everything that went on: tool calls, reasoning, whatever.

The workflow body is just TypeScript. Use if, for, while, try, await, helper functions, and whatever domain code you already have. The orchestration runtime only cares about the things you yield*.

Each yield* agents.someAgent(...) becomes a typed step. The runtime can emit lifecycle events around it, stream text while it runs, validate the result, snapshot state, and resume the generator with the typed output.

Workflows run on the server. The browser consumes an event stream.

The PR adds parseWorkflowRequest, runWorkflow, and inMemoryRunStore from @tanstack/ai-orchestration. You can pipe the returned stream through the existing toServerSentEventsResponse helper from @tanstack/ai.

``` js
import { toServerSentEventsResponse } from '@tanstack/ai'
import {
  inMemoryRunStore,
  parseWorkflowRequest,
  runWorkflow,
} from '@tanstack/ai-orchestration'
import { articleWorkflow } from './article-workflow'

const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 })

export async function POST(request: Request) {
  const params = await parseWorkflowRequest(request)
  const stream = runWorkflow({
    workflow: articleWorkflow,
    runStore,
    ...params,
  })

  return toServerSentEventsResponse(stream)
}
js
import { toServerSentEventsResponse } from '@tanstack/ai'
import {
  inMemoryRunStore,
  parseWorkflowRequest,
  runWorkflow,
} from '@tanstack/ai-orchestration'
import { articleWorkflow } from './article-workflow'

const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 })

export async function POST(request: Request) {
  const params = await parseWorkflowRequest(request)
  const stream = runWorkflow({
    workflow: articleWorkflow,
    runStore,
    ...params,
  })

  return toServerSentEventsResponse(stream)
}
```

runWorkflow emits AG-UI-style lifecycle events for the run. That includes run and step events, state snapshots, JSON Patch state deltas, output, and errors. The UI does not need to invent its own event protocol for "writer started", "editor streamed text", "approval requested", or "run finished".

The current built-in persistence is inMemoryRunStore. That is useful for local demos and single-process evaluation. Production durability is still future-facing and experimental run-store-interface territory, especially for long pauses, deploys, restarts, and multi-node environments. But the API is there to implement your own durable run store and swap it in when you're ready.

On the client, WorkflowClient, useWorkflow, and useOrchestration consume the streamed events and keep local run state updated.

``` js
import { fetchWorkflowEvents, useWorkflow } from '@tanstack/ai-react'
import { z } from 'zod'

const ArticleSchema = z.object({
  title: z.string(),
  body: z.string(),
})

const ArticleInputSchema = z.object({
  topic: z.string(),
})

const ArticleWorkflowOutputSchema = z.discriminatedUnion('ok', [
  z.object({
    ok: z.literal(true),
    article: ArticleSchema,
  }),
  z.object({
    ok: z.literal(false),
    reason: z.string(),
  }),
])

export function ArticleWorkflowDemo() {
  const workflow = useWorkflow({
    input: ArticleInputSchema,
    output: ArticleWorkflowOutputSchema,
    connection: fetchWorkflowEvents('/api/article-workflow'),
  })

  return (
    <section>
      <button
        type="button"
        disabled={workflow.status === 'running'}
        onClick={() =>
          workflow.start({
            topic: 'How typed agent workflows improve AI product UX',
          })
        }
      >
        Start workflow
      </button>

      {workflow.steps.map((step) => (
        <div key={step.stepId}>
          <strong>{step.stepName}</strong>
          <span>{step.status}</span>
        </div>
      ))}

      {workflow.currentText ? <pre>{workflow.currentText}</pre> : null}

      {workflow.pendingApproval ? (
        <div>
          <h2>{workflow.pendingApproval.title}</h2>
          <p>{workflow.pendingApproval.description}</p>
          <button type="button" onClick={() => workflow.approve(true)}>
            Approve
          </button>
          <button
            type="button"
            onClick={() => workflow.approve(false, 'Tighten the conclusion.')}
          >
            Request changes
          </button>
        </div>
      ) : null}

      {workflow.output?.ok ? (
        <article>
          <h1>{workflow.output.article.title}</h1>
          <p>{workflow.output.article.body}</p>
        </article>
      ) : null}

      {workflow.output && !workflow.output.ok ? (
        <p>{workflow.output.reason}</p>
      ) : null}
    </section>
  )
}
js
import { fetchWorkflowEvents, useWorkflow } from '@tanstack/ai-react'
import { z } from 'zod'

const ArticleSchema = z.object({
  title: z.string(),
  body: z.string(),
})

const ArticleInputSchema = z.object({
  topic: z.string(),
})

const ArticleWorkflowOutputSchema = z.discriminatedUnion('ok', [
  z.object({
    ok: z.literal(true),
    article: ArticleSchema,
  }),
  z.object({
    ok: z.literal(false),
    reason: z.string(),
  }),
])

export function ArticleWorkflowDemo() {
  const workflow = useWorkflow({
    input: ArticleInputSchema,
    output: ArticleWorkflowOutputSchema,
    connection: fetchWorkflowEvents('/api/article-workflow'),
  })

  return (
    <section>
      <button
        type="button"
        disabled={workflow.status === 'running'}
        onClick={() =>
          workflow.start({
            topic: 'How typed agent workflows improve AI product UX',
          })
        }
      >
        Start workflow
      </button>

      {workflow.steps.map((step) => (
        <div key={step.stepId}>
          <strong>{step.stepName}</strong>
          <span>{step.status}</span>
        </div>
      ))}

      {workflow.currentText ? <pre>{workflow.currentText}</pre> : null}

      {workflow.pendingApproval ? (
        <div>
          <h2>{workflow.pendingApproval.title}</h2>
          <p>{workflow.pendingApproval.description}</p>
          <button type="button" onClick={() => workflow.approve(true)}>
            Approve
          </button>
          <button
            type="button"
            onClick={() => workflow.approve(false, 'Tighten the conclusion.')}
          >
            Request changes
          </button>
        </div>
      ) : null}

      {workflow.output?.ok ? (
        <article>
          <h1>{workflow.output.article.title}</h1>
          <p>{workflow.output.article.body}</p>
        </article>
      ) : null}

      {workflow.output && !workflow.output.ok ? (
        <p>{workflow.output.reason}</p>
      ) : null}
    </section>
  )
}
```

There is also useOrchestration for the same runtime with orchestration vocabulary. If your mental model is "run a workflow", use useWorkflow. If your mental model is "let a router pick the next agent", use useOrchestration.

``` js
import { fetchWorkflowEvents, useOrchestration } from '@tanstack/ai-react'

export function ArticleOrchestrationDemo() {
  const orchestration = useOrchestration({
    input: ArticleInputSchema,
    output: ArticleWorkflowOutputSchema,
    connection: fetchWorkflowEvents('/api/article-orchestrator'),
  })

  return (
    <button
      type="button"
      disabled={orchestration.status === 'running'}
      onClick={() =>
        orchestration.start({
          topic: 'How typed orchestration improves AI product UX',
        })
      }
    >
      Start orchestration
    </button>
  )
}
js
import { fetchWorkflowEvents, useOrchestration } from '@tanstack/ai-react'

export function ArticleOrchestrationDemo() {
  const orchestration = useOrchestration({
    input: ArticleInputSchema,
    output: ArticleWorkflowOutputSchema,
    connection: fetchWorkflowEvents('/api/article-orchestrator'),
  })

  return (
    <button
      type="button"
      disabled={orchestration.status === 'running'}
      onClick={() =>
        orchestration.start({
          topic: 'How typed orchestration improves AI product UX',
        })
      }
    >
      Start orchestration
    </button>
  )
}
```

Human-in-the-loop control is not a side channel in this PR.

When workflow code calls yield* approve(...), the runtime emits an approval-requested event, persists the paused run in the run store, and closes the SSE response. The HTTP request is done. The server does not keep a socket open while a user thinks, checks a diff, or gets approval from someone else.

When the client calls approve(), it POSTs back to the same endpoint with the run id and approval result. runWorkflow resumes the generator and the next SSE response continues from the paused point.

That means approvals, revisions, and denial feedback can be modeled in the workflow itself:

``` js
const decision =
  yield *
  approve({
    title: 'Publish article?',
    description: edited.notes,
  })

if (!decision.approved) {
  const revised =
    yield *
    agents.editor({
      article: edited.article,
      feedback: decision.feedback ?? 'Revise before publishing.',
    })

  return succeed({ article: revised.article })
}
js
const decision =
  yield *
  approve({
    title: 'Publish article?',
    description: edited.notes,
  })

if (!decision.approved) {
  const revised =
    yield *
    agents.editor({
      article: edited.article,
      feedback: decision.feedback ?? 'Revise before publishing.',
    })

  return succeed({ article: revised.article })
}
```

The same event stream also carries state snapshots and JSON Patch deltas, so a UI can show a live state inspector, a timeline, or a draft preview without waiting for the final result.

Use a workflow when you know the pipeline.

Writer, then editor, then approval is a workflow. Extract topics, draft outline, expand sections is a workflow. Run static checks, ask for approval, deploy to staging, ask again, deploy to production is a workflow.

Use an orchestrator when the next step should be selected turn by turn.

defineOrchestrator is a thin wrapper over workflows, so the workflow behavior already described applies the same way to orchestrators. It uses the same runtime pieces: typed agents, streaming steps, state snapshots, approvals, SSE transport, and React hooks.

The body is a router. The router looks at input, mutable state, and the previous result, then returns the next agent to run. When the router is done, it returns the final output.

``` js
import {
  defineOrchestrator,
  defineRouter,
  succeed,
} from '@tanstack/ai-orchestration'
import { z } from 'zod'
import { ArticleSchema, editor, writer } from './agents'

const ArticleStateSchema = z.object({
  draft: ArticleSchema.optional(),
  approved: z.boolean().default(false),
})

const articleRouter = defineRouter(
  {
    name: 'article-router',
    state: ArticleStateSchema,
  },
  async function* ({ input, state }) {
    if (!state.draft) {
      return {
        agent: 'writer',
        input: {
          topic: input.topic,
        },
      }
    }

    if (!state.approved) {
      return {
        agent: 'editor',
        input: {
          article: state.draft,
        },
      }
    }

    return {
      done: true,
      output: succeed({
        article: state.draft,
      }),
    }
  },
)

export const articleOrchestrator = defineOrchestrator({
  name: 'article-orchestrator',
  input: ArticleInputSchema,
  output: ArticleWorkflowOutputSchema,
  agents: {
    writer,
    editor,
  },
  router: articleRouter,
})
js
import {
  defineOrchestrator,
  defineRouter,
  succeed,
} from '@tanstack/ai-orchestration'
import { z } from 'zod'
import { ArticleSchema, editor, writer } from './agents'

const ArticleStateSchema = z.object({
  draft: ArticleSchema.optional(),
  approved: z.boolean().default(false),
})

const articleRouter = defineRouter(
  {
    name: 'article-router',
    state: ArticleStateSchema,
  },
  async function* ({ input, state }) {
    if (!state.draft) {
      return {
        agent: 'writer',
        input: {
          topic: input.topic,
        },
      }
    }

    if (!state.approved) {
      return {
        agent: 'editor',
        input: {
          article: state.draft,
        },
      }
    }

    return {
      done: true,
      output: succeed({
        article: state.draft,
      }),
    }
  },
)

export const articleOrchestrator = defineOrchestrator({
  name: 'article-orchestrator',
  input: ArticleInputSchema,
  output: ArticleWorkflowOutputSchema,
  agents: {
    writer,
    editor,
  },
  router: articleRouter,
})
```

Routers may also yield if their decision needs async work, but their return value is the important part: either the next { agent, input } pair to run or { done, output } when orchestration is complete.

That gives you the same behavior with a different control-flow style. A workflow puts the sequence directly in the generator body. An orchestrator puts the next-step decision in a router.

Almost everything important enough to name.

The package name is @tanstack/ai-orchestration, the public feature name is TanStack AI Workflows & Orchestrators, and the core shape is visible in PR 542. But this is still a PR build.

Expect scrutiny around:

The current implementation is useful enough to try and early enough to change.

If you are building multi-step AI product flows, this is the moment to test the shape.

Try the [PR build packages](https://github.com/TanStack/ai/pull/542#issuecomment-4416347869). Read the branch docs for [workflows](https://github.com/TanStack/ai/blob/worktree-cryptic-singing-wadler/docs/orchestration/workflows.md), [orchestrators](https://github.com/TanStack/ai/blob/worktree-cryptic-singing-wadler/docs/orchestration/orchestrators.md), [approvals](https://github.com/TanStack/ai/blob/worktree-cryptic-singing-wadler/docs/orchestration/approvals.md), [run persistence](https://github.com/TanStack/ai/blob/worktree-cryptic-singing-wadler/docs/orchestration/run-persistence.md), and the [API reference](https://github.com/TanStack/ai/blob/worktree-cryptic-singing-wadler/docs/api/ai-orchestration.md).

Then share demos, feedback, and rough edges on [PR 542](https://github.com/TanStack/ai/pull/542) before the API stabilizes.
