Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ E2E_DEFAULT_MODEL=
# If you don't have PostgreSQL running locally, start it with: pnpm docker:pg
POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name

# Secret used to authorize workflow scheduler dispatches (set any random string)
WORKFLOW_SCHEDULER_SECRET=

# Secret for Better Auth (generate with: npx @better-auth/cli@latest secret)
BETTER_AUTH_SECRET=****

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Built with Vercel AI SDK and Next.js, combining the best features of leading AI
- [🔐 OAuth Sign-In Setup](#-oauth-sign-in-setup)
- [🕵🏿 Adding openAI like providers](#-adding-openai-like-providers)
- [🧪 E2E Testing Guide](#-e2e-testing-guide)
- [🕒 Workflow Scheduler](#-workflow-scheduler)
- [💡 Tips](#-tips)
- [💬 Temporary Chat Windows](#-temporary-chat-windows)
- [🗺️ Roadmap](#️-roadmap)
Expand Down Expand Up @@ -306,6 +307,10 @@ BETTER_AUTH_URL=
# If you don't have PostgreSQL running locally, start it with: pnpm docker:pg
POSTGRES_URL=postgres://your_username:your_password@localhost:5432/your_database_name

# === Workflow Scheduler ===
# Shared secret required by /api/workflow/schedules/dispatch
WORKFLOW_SCHEDULER_SECRET=your_random_string

# (Optional)
# === Tools ===
# Exa AI for web search and content extraction (optional, but recommended for @web and research features)
Expand Down Expand Up @@ -395,6 +400,10 @@ Step-by-step setup guides for running and configuring better-chatbot.
#### [🧪 E2E Testing Guide](./docs/tips-guides/e2e-testing-guide.md)

- Comprehensive end-to-end testing with Playwright including multi-user scenarios, agent visibility testing, and CI/CD integration

#### [🕒 Workflow Scheduler](./docs/tips-guides/workflow-scheduler.md)

- Configure Scheduler nodes, set the shared secret, and wire cron jobs to `/api/workflow/schedules/dispatch`
<br/>

## 💡 Tips
Expand Down
129 changes: 129 additions & 0 deletions docs/tips-guides/workflow-scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Workflow Scheduler

Trigger published workflows on a recurring cadence by combining the Scheduler node with the `/api/workflow/schedules/dispatch` endpoint. This guide explains what you need to configure, how schedule execution works, and a few examples for wiring it up to cron jobs or external task runners.

## Requirements

- **Workflow Scheduler Secret** – set `WORKFLOW_SCHEDULER_SECRET` in `.env` (any random string). Every dispatch request must present this secret.
- **Published workflow** – only published workflows that contain at least one Scheduler node are eligible to run. Draft workflows are ignored.
- **Cron or job runner** – you must call the dispatch endpoint on a cadence (e.g., Vercel Cron, GitHub Actions, Cloudflare Workers, Kubernetes CronJob, or a local `cron` entry).

## Configuring Scheduler Nodes

1. Add a **Scheduler** node to your workflow and fill in:
- `cron` – standard 5-part cron expression (validated via `cron-parser`).
- `timezone` – Olson TZ string (defaults to the workflow owner's timezone).
- `enabled` – scheduler rows are skipped when disabled.
- `payload` – optional JSON object passed as the workflow input when this schedule runs.
2. Publish the workflow. Saving/publishing will upsert the node's schedule in the `workflow_schedule` table and compute the next run time.

When a schedule fires, the workflow executor runs with:

- The node's `payload` merged into the execution `query`.
- Optional workflow context containing the owner's id, name, and email (when available).
- History disabled and a 5-minute timeout to keep scheduler runs short-lived.

## Dispatch Endpoint

```
POST /api/workflow/schedules/dispatch
```

### Authentication

Send the scheduler secret by using one of the supported headers:

- `Authorization: Bearer <WORKFLOW_SCHEDULER_SECRET>`
- `x-workflow-scheduler-secret: <WORKFLOW_SCHEDULER_SECRET>`
- `x-cron-secret: <WORKFLOW_SCHEDULER_SECRET>`

The request is rejected with `401 Unauthorized` when the secret is missing or mismatched. A `500` error indicates the secret is not configured on the server.

### Request Body

`Content-Type: application/json` with the following optional fields:

- `limit` – maximum number of schedules to process (default `5`, min `1`, max `25`).
- `dryRun` – when `true`, schedules are locked then immediately released (useful for monitoring or smoke tests).

### Response Shape

```json
{
"ok": true,
"result": {
"scanned": 3,
"locked": 2,
"success": 2,
"failed": 0,
"skipped": 1,
"errors": []
}
}
```

- `scanned` – due schedules inspected during this dispatch.
- `locked` – schedules successfully locked by this worker.
- `success` / `failed` – execution outcome counts.
- `skipped` – schedules skipped because they were already locked, disabled, or the request was a dry run.
- `errors` – array of `{ scheduleId, message }` entries for failed runs.

Locks automatically expire after five minutes to protect against stuck workers. Each successful run recomputes the next run time using the stored cron expression.

## Example Cron Invocations

### Local cron (every minute)

```bash
* * * * * curl -s -X POST \
-H "x-workflow-scheduler-secret: $WORKFLOW_SCHEDULER_SECRET" \
https://your-domain.com/api/workflow/schedules/dispatch > /dev/null
```

### Vercel Cron Job

1. Set `WORKFLOW_SCHEDULER_SECRET` in your Vercel project settings.
2. Add a cron entry in `vercel.json`:

```json
{
"crons": [
{
"path": "/api/workflow/schedules/dispatch",
"schedule": "*/5 * * * *",
"headers": {
"x-workflow-scheduler-secret": "@WORKFLOW_SCHEDULER_SECRET"
}
}
]
}
```

Vercel automatically injects the secret value referenced by the `@` syntax.

### GitHub Actions

```yaml
name: Workflow Scheduler
on:
schedule:
- cron: "*/10 * * * *"
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Trigger schedules
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.WORKFLOW_SCHEDULER_SECRET }}" \
https://your-domain.com/api/workflow/schedules/dispatch
```

## Troubleshooting

- **`Unauthorized`** – confirm the header value matches `WORKFLOW_SCHEDULER_SECRET` on the server.
- **`ok: true` but `skipped` > 0** – another worker already locked those schedules, or the request used `dryRun: true`.
- **Workflows never run** – ensure the workflow is published and the Scheduler node is enabled with a valid cron + timezone.
- **Need visibility** – temporarily run with `dryRun: true` to gather lock stats without executing flows.

With these steps in place, your Scheduler nodes will run reliably at whatever cadence you define.
21 changes: 19 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,32 @@
"code": "Execute custom code scripts with access to previous node data.\n\nRun JavaScript, Python, or other languages within your workflow (coming soon).",
"http": "Fetch data from external APIs and web services via HTTP requests.\n\nIntegrate with REST APIs, webhooks, and third-party services.",
"template": "Create dynamic documents by combining text with data from previous nodes.\n\nGenerate emails, reports, or formatted content using variable substitution.",
"condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions."
"condition": "Add conditional logic to branch your workflow based on data evaluation.\n\nCreate if-else logic to handle different scenarios and data conditions.",
"reply-in-thread": "Create a new chat thread for the current user with scripted messages.\n\nUse '/' mentions to inject data from previous nodes before saving the conversation.",
"scheduler": "Trigger workflows on a recurring schedule defined by cron expressions.\n\nUse timezone-aware schedules to automate recurring tasks without manual input."
},
"structuredOutputSwitchConfirm": "You currently have structured output enabled.\n What would you like to do?",
"structuredOutputSwitchConfirmOk": "Edit Structured Output",
"structuredOutputSwitchConfirmCancel": "Change to Text Output",
"noTools": "No published workflows available.\nCreate workflows to build custom tools.",
"arrangeNodes": "Auto Layout",
"nodesArranged": "Layout applied successfully",
"visibilityUpdated": "Visibility updated successfully"
"visibilityUpdated": "Visibility updated successfully",
"schedulerCronExpression": "Cron expression",
"schedulerCronHelper": "Use standard 5-field cron syntax. Examples: '0 * * * *' or '0 9 * * MON'.",
"schedulerCronDocs": "Open crontab.guru",
"schedulerTimezone": "Timezone",
"schedulerTimezoneHelper": "Use an IANA timezone like 'UTC' or 'America/New_York'.",
"schedulerEnabled": "Enabled",
"schedulerEnabledDescription": "Paused schedules will not run.",
"schedulerPayload": "Payload",
"schedulerPayloadDescription": "JSON payload supplied as workflow input when the schedule triggers.",
"schedulerInvalidJson": "Please enter valid JSON.",
"schedulerStackCronLabel": "Cron",
"schedulerStackTimezoneLabel": "TZ",
"schedulerStackStatusLabel": "Status",
"schedulerStatusActive": "Active",
"schedulerStatusPaused": "Paused"
},
"Auth": {
"SignIn": {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"consola": "^3.4.2",
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.6.1",
Expand Down
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { nanoBananaTool, openaiImageTool } from "lib/ai/tools/image";
import { ImageToolName } from "lib/ai/tools";
import { buildCsvIngestionPreviewParts } from "@/lib/ai/ingest/csv-ingest";
import { serverFileStorage } from "lib/file-storage";
import type { WorkflowExecutionContext } from "lib/ai/workflow/workflow.interface";

const logger = globalLogger.withDefaults({
message: colorize("blackBright", `Chat API: `),
Expand All @@ -65,6 +66,13 @@ export async function POST(request: Request) {
if (!session?.user.id) {
return new Response("Unauthorized", { status: 401 });
}
const workflowContext: WorkflowExecutionContext = {
user: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
},
};
const {
id,
message,
Expand Down Expand Up @@ -225,6 +233,7 @@ export async function POST(request: Request) {
loadWorkFlowTools({
mentions,
dataStream,
context: workflowContext,
}),
)
.orElse({});
Expand Down
20 changes: 17 additions & 3 deletions src/app/api/chat/shared.chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ import {
VercelAIWorkflowToolTag,
} from "app-types/workflow";
import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor";
import { NodeKind } from "lib/ai/workflow/workflow.interface";
import {
NodeKind,
WorkflowExecutionContext,
withWorkflowContext,
} from "lib/ai/workflow/workflow.interface";
import { mcpClientsManager } from "lib/ai/mcp/mcp-manager";
import { APP_DEFAULT_TOOL_KIT } from "lib/ai/tools/tool-kit";
import { AppDefaultToolkit } from "lib/ai/tools";
Expand Down Expand Up @@ -225,12 +229,14 @@ export const workflowToVercelAITool = ({
schema,
dataStream,
name,
context,
}: {
id: string;
name: string;
description?: string;
schema: ObjectJsonSchema7;
dataStream: UIMessageStreamWriter;
context?: WorkflowExecutionContext;
}): VercelAIWorkflowTool => {
const toolName = name
.replace(/[^a-zA-Z0-9\s]/g, "")
Expand Down Expand Up @@ -316,9 +322,14 @@ export const workflowToVercelAITool = ({
output: toolResult,
});
});
const runtimeQuery = withWorkflowContext(
(query ?? undefined) as Record<string, unknown> | undefined,
context,
);

return executor.run(
{
query: query ?? ({} as any),
query: runtimeQuery,
},
{
disableHistory: true,
Expand Down Expand Up @@ -376,12 +387,14 @@ export const workflowToVercelAITools = (
schema: ObjectJsonSchema7;
}[],
dataStream: UIMessageStreamWriter,
context?: WorkflowExecutionContext,
) => {
return workflows
.map((v) =>
workflowToVercelAITool({
...v,
dataStream,
context,
}),
)
.reduce(
Expand Down Expand Up @@ -409,6 +422,7 @@ export const loadMcpTools = (opt?: {
export const loadWorkFlowTools = (opt: {
mentions?: ChatMention[];
dataStream: UIMessageStreamWriter;
context?: WorkflowExecutionContext;
}) =>
safe(() =>
opt?.mentions?.length
Expand All @@ -422,7 +436,7 @@ export const loadWorkFlowTools = (opt: {
)
: [],
)
.map((tools) => workflowToVercelAITools(tools, opt.dataStream))
.map((tools) => workflowToVercelAITools(tools, opt.dataStream, opt.context))
.orElse({} as Record<string, VercelAIWorkflowTool>);

export const loadAppDefaultTools = (opt?: {
Expand Down
Loading