Lowdefy v6 swaps the framework under your app. Next.js is gone. The server is a Hono app, the client is a Vite-built React bundle, and authentication runs on Auth.js. Your YAML does not change. lowdefy dev, lowdefy build and lowdefy start do what they did before, and the migration guide fits on one screen.
What you get for the swap: a dev server that reloads a plugin change in about 700 milliseconds instead of rebuilding for 20 to 40 seconds, and a production server you copy into a container and run with one command. The rest of the release is built on that base. Realtime channels, notification emails, server-resolved pages, cron schedules, LLM calls as routine steps, your API exposed as an MCP server, and a set of tools that let a coding agent see what it built.
Hono and Vite replace Next.js
The v5 dev loop had a hard floor. Changing a plugin meant a Next.js rebuild and a server restart. In v6 the dev server runs Vite with the Hono app mounted as middleware, so plugin changes go through Vite's hot module replacement and config changes push a reload over server-sent events. Warm boots skip the install step entirely when package.json has not changed, which drops a restart from around 30 seconds to a few.
Page navigation is now client-side. The first page load embeds config in the HTML. Navigating fetches the next page's config from the API without a full browser reload, so page state and the websocket connection survive a route change.
Production builds write a complete, runnable server to .lowdefy/server:
lowdefy build cd .lowdefy/server node src/index.js
That folder is what you deploy. LOWDEFY_BUILD_OUTPUT_STANDALONE is gone because there is no other kind of output.
The breaking changes are short. Sessions invalidate once, because the cookie prefix changes from next-auth.* to authjs.*. NEXTAUTH_SECRET becomes AUTH_SECRET, and the build fails with a config error if auth providers are configured without it. A custom next.config.js no longer applies; bundler customizations move to a vite.config.js in the server directory. NEXT_PUBLIC_SENTRY_DSN becomes SENTRY_DSN, read at runtime, so rotating the DSN no longer requires a rebuild. The auth: schema itself, with its providers, adapters, callbacks and protected pages, is unchanged.
Websockets
A new top-level websockets: key defines channels, and pages subscribe to them with subscriptions:. The Lowdefy server that serves your pages also pushes the messages, over one multiplexed connection, locally and on Vercel. No polling, no separate socket service.
websockets: - id: ticker type: Interval properties: ms: 1000 pages: - id: dashboard type: PageHeaderMenu subscriptions: - websocketId: ticker blocks: - id: ticks type: Html properties: html: _string.concat: - 'Ticks: ' - _websocket: ticker.lastMessage.tick
Channel types are plugins. Channel is a pub/sub relay and Interval emits timed ticks; both ship in @lowdefy/websockets-core. MongoDBChangeStream in the MongoDB connection pushes change events from a collection to subscribed pages. Channel properties are evaluated on the server per subscription, so _user and _payload make a channel user-specific, and subscribers whose evaluated properties are identical share one running source.
Pages subscribe on mount and unsubscribe on navigation. React to messages with onMessage, onSubscribe and onError events, or read channel state anywhere with the _websocket operator. New Publish, Subscribe and Unsubscribe actions cover the dynamic cases. Channel access follows the same auth pattern as protected endpoints, with public, protected and roles lists under auth.websockets.
The client reconnects with backoff and resubscribes on its own, so a deploy or a serverless function hitting its time limit is invisible to users. Start with the websockets introduction.
Notification emails
Apps can define notifications in config: branded emails rendered from framework templates, sent over any SMTP provider. The framework renders. Storing the notification record and sending it stay in your routines, so any database works through its normal request types.
notifications: - id: task-assigned type: NotificationEmail properties: subject: 'New task: {{ task.title }}' title: Task assigned to you message: | Hi {{ contact.name }}, **{{ task.title }}** has been assigned to you. metadata: - label: Due value: '{{ task.due_date }}' button: label: View task
Template properties are Nunjucks data templates, and interpolated values are inert. A task title of [click here](https://evil.example) renders as literal text, never a link. A RenderNotification routine step renders one data item and returns { subject, title, preview, html, text, data }, with { pageId, urlQuery } links resolved to full URLs. Three templates ship: NotificationEmail, DigestEmail and AlertEmail. Custom templates are plain React Email plugin packages.
Branding lives under app.email and defaults from config you already have: companyName from the app's name, primaryColor from theme.antd.token.colorPrimary, and a logo that can be a path into public/. The new SMTP connection wraps nodemailer and works with SES, Postmark, Mailgun, Resend or a self-hosted server. Both SMTP and SendGrid accept a delivery filter so staging can redirect every email to a test inbox, and both return per-message send results.
lowdefy emails builds the app, renders a preview of every notification from its testData, and opens it in React Email's preview server. Modules can ship their own templates, scoped to the module entry, so installing a user-admin module twice never collides.
Dynamic page content
Requests change the data on a page. The new Dynamic block changes the blocks. Point it at an API endpoint, and at page load the server runs that endpoint's routine in-process, validates the block config it returns, splices it into the page, and sends the result to the client. The client renders it like any other page.
- id: insights type: Dynamic properties: endpointId: resolve_insights params: area: insights types: blocks: - Statistic slots: fallback: blocks: - id: insights_unavailable type: Html properties: html: Insights are unavailable right now.
The endpoint is a normal InternalApi routine. It receives { params, pageId, blockId, urlQuery } and returns { blocks: [...] }. Returned blocks are checked before they reach the client: types must be in the client bundle, properties are validated against block schemas, and Request action references must exist on the page. Client-side operators in returned config are escaped with one extra underscore, __state for _state, the same convention _function uses. If resolution fails, the fallback slot renders and the page still loads, unless you set required: true.
A dashboard whose sections depend on a plan or a feature flag, a form generated from a workflow definition in a database, a kanban board whose columns come from tenant config: these used to need a plugin or a rebuild. Now they are a routine.
Scheduled endpoints
Api and InternalApi endpoints can declare schedules to run on a timer:
id: purge-stale-conversations type: Api schedules: - cron: '0 6 * * *' payload: { mode: full } - cron: '*/15 * * * *' payload: { mode: incremental } routine: - id: purge type: MongoDBDeleteMany connectionId: conversations properties: filter: { updatedAt: { $lt: { _payload: cutoff } } }
The build validates the cron syntax and writes a schedules.json manifest. On Vercel, lowdefy vercel-output generates the crons array from it on every deploy, so nothing is committed by hand. Scheduled runs hit /api/cron/* as a system context with no user session, secured by CRON_SECRET, and fail closed when it is unset.
Vercel only fires cron jobs on the production deployment, which meant staging schedules never ran. config.cron.environments declares your deployments once, and production forwards each environment's schedules to that environment's own cron route. Schedules can then be keyed by environment, with a default that others inherit and [] to switch crons off for a branch deploy.
Three more controls cover serverless execution. async: true on an endpoint answers { accepted: true } immediately and runs the routine in the background. detached: true on a CallApi step runs the target in its own invocation with a fresh duration budget. webhook: true turns an endpoint into a third-party webhook receiver that gets the raw { body, query, headers } and returns its response body verbatim, with caller verification as the routine's first step.
LLM calls as routine steps
Every AI provider connection, Anthropic, OpenAI, Google and the AI Gateway, gains two request types: GenerateText and GenerateObject. They are single model calls usable as routine steps or page requests, and the type names are shared, so switching providers means changing a connectionId.
routine: - id: classify type: GenerateObject connectionId: claude properties: model: claude-haiku-4-5 prompt: _payload: ticket_text schema: type: object properties: category: { type: string } priority: { type: string, enum: [low, normal, urgent] }
GenerateObject returns data matching the JSON Schema you pass, which is the shape most routine decisions want: classify this ticket, extract these fields, pick this branch.
For work that needs tools, the new CallAgent step runs one of your agents to completion inside a routine. No chat UI, no stream. The agent loops through its tools until done and stores { text, finishReason, usage, toolCalls, toolResults } in _step. Combined with schedules, a routine can run an agent every morning that summarizes yesterday's signups and flags anomalies, using the same endpoints the chat interface uses.
Your API as an MCP server
A root mcp block lists the Api endpoints to expose as tools, and the server serves them at /api/mcp over streamable HTTP:
mcp: name: my-app version: '1.0.0' title: My App websiteUrl: https://example.com icons: - src: https://example.com/icon-512.png mimeType: image/png sizes: ['512x512'] endpoints: - create-ticket - search-customers
An endpoint's description and payloadSchema become the tool description and input schema, so both are required for exposed endpoints. Tool calls are authorized per request with the caller's session and flow through the same _user machinery as a button click.
Clients that cannot hold a session cookie, an MCP client or a service, authenticate through auth.strategies. An apiKey strategy reads an X-API-Key header, a jwt strategy reads a bearer token, and each grants the caller the strategy's roles. Unauthenticated calls to role-gated endpoints now return a 401 instead of a masked error.
File storage on any provider
The S3 blocks are replaced by provider-neutral ones in @lowdefy/blocks-files: Upload, UploadPhoto, UploadDragger and Download. Upload blocks call an upload-policy request by id and support both POST form uploads and PUT body uploads, with progress on both. AwsS3Bucket connections accept an endpoint and forcePathStyle, which is the one-line change that makes Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2 and Wasabi work. New GoogleCloudStorage and AzureBlobStorage plugins cover the other two clouds with matching request types.
emitFileContent: true reads the file in the browser and emits { name, size, type, content } as the block value, for apps that store files through a routine and a server-side write request. Tiptap editors and AgentChat attachments upload through the same flow. The old S3UploadButton family keeps working as deprecated aliases, and lowdefy upgrade includes a codemod that renames them. See hosting files.
Control flow in event actions
Event action lists accept the same :if, :switch and :return controls that API routines use:
events: onClick: - :if: _not: _state: form_valid :then: - id: warn type: Message params: content: Fix the highlighted fields first. - :return: null - id: save type: Request params: save_record
Before, gating a group of actions meant repeating the same skip expression on each one, and ending an event early meant a Throw you then had to catch. Controls nest and work in both try and catch lists. Actions not executed for a control-flow reason are reported as skipped, so _actions lookups and action indices are unchanged for existing config.
A dev server built for coding agents
Most of the work on the dev server in this release is aimed at a developer who is not a human. The dev server always serves /lowdefy-docs, a REST and MCP endpoint with the schema, examples and docs for every block, operator, action, connection and request type installed in the project, including your local plugins. An agent looks up the exact contract instead of guessing at property names.
Then it closes the loop. lowdefy_build_status returns the current build errors and warnings with file and line, plus recent browser runtime errors. lowdefy_screenshot_page returns a PNG of the rendered page from headless Chromium. lowdefy_inspect_state reads the live state, request results and event log of the page open in your browser tab, so you can click through a bug yourself and let the agent look at exactly what you see. lowdefy_eval_operator is a REPL for operator expressions against that live state. lowdefy_snapshot_state and lowdefy_load_state capture a page mid-scenario into a checkpoint and restore it, and a ?_checkpoint=<name> URL hands a teammate the app in that state. There are 23 tools in total, listed in the docs for AI agents.
npx lowdefy agent-setup
One command writes a .mcp.json, a Claude Code skill and an AGENTS.md section into your project, at the repo root in a monorepo, appended to an existing CLAUDE.md when there is one. lowdefy dev --mock-user '{"roles":["admin"]}' starts the dev server signed in, so the headless renderer can screenshot protected pages. Each agent tool call can also pass its own user, rendering as an admin on one call and a plain member on the next.
Two features are for the human in the loop. Hold Option or Alt and click any element in the running app to open the YAML that defines it in VS Code, at the exact line. Press Cmd+/ or Ctrl+/ to get an annotation overlay: select blocks, draw on the page, write comments, and copy a feedback block to your clipboard where each annotation carries its blockId, its YAML file and line, and an annotated screenshot. Paste it into whichever agent session you are working in.
Running several apps at once
Cookies are scoped by host, not port, so two dev servers on localhost used to share one auth cookie jar, and signing into one app signed you out of the other. The dev server now derives a cookie prefix from the app slug or name, so each app gets its own session, and the CLI picks the next free port instead of failing when the requested one is busy. Set the same auth.advanced.cookiePrefix on two apps to share a session on purpose.
Deployment
Docker. The server serves GET /api/lowdefy-health for container probes, skipping auth, logging and Sentry so frequent checks stay out of your logs. On SIGTERM it closes websocket clients, finishes in-flight requests, flushes Sentry and exits within Docker's grace period. A new lowdefy docker-output command traces the server's runtime dependency graph and copies only the files it imports into .lowdefy/docker, so build tooling and client-only block packages stay out of the image. lowdefy init-docker writes a Dockerfile that pins the CLI to your app's lowdefy: version, runs as the non-root node user, and adds a HEALTHCHECK. See the Docker guide.
Vercel. lowdefy init-vercel scaffolds a complete deployment into deploy/: static assets on the CDN, one serverless function for everything else, built through the Build Output API. config.vercel sets maxDuration and memory, and a new config.requestTimeout bounds every request, defaulting to 30 seconds, so a hung upstream call cannot run to the platform limit on a plan billed by duration. See the Vercel guide.
Logs. Every request logs its final status and duration_ms as a standard access log line. Every line carries app_name, app_version and git_sha, so errors correlate to a build across replicas during a rolling deploy. The request id honours an upstream x-request-id header and echoes it on the response.
Smaller changes
- The
_appoperator reads the app'sslug,name,version,description,license,lowdefyVersionandgitSha, with identical values at build time and runtime.slugis a new root field, validated as kebab-case, and referencing it when it is not declared fails the build rather than scoping namespaced data undernull. TagSelectorandTagMultipleSelectorrender options as toggleable pills with a stable colour per value.- The date picker blocks accept
presetsfor quick ranges like "Last 7 days" and "Month to date", built with_dayjsand re-evaluated on every render. - AG Grid gains a
menucell that puts a row's actions behind one trigger button instead of a wide column of buttons. - The
Linkaction acceptsreplaceandscroll, so a same-page link that only updatesurlQueryno longer jumps to the top or pushes a history entry. AgentChatwelcome screens supporttracks, labelled columns of starter prompts that fill the composer instead of sending, and asetInputmethod.- The
state-refsbuild check warns instead of failing production builds. State can be created at runtime, so the check is a heuristic, and failing a deploy on a false positive was worse than the miss. - Module deferral moved onto a single build-level registry. Entry order no longer matters for modules that embed each other, and cycle errors name the actual value chain.
- The Google Sheets connection moved to the current
google-spreadsheetlibrary, which also fixes a crash on Node.js 26.
Upgrading
npx lowdefy upgrade
The upgrade command detects your version and generates the codemod prompts for the changes between it and v6. Because the config schema is unchanged, most apps need only the environment variable renames and a one-time sign-in. The full list of breaking changes is in the v5 to v6 migration guide.