# Ploy — Full Documentation > Ploy is an open-source, self-hostable serverless deployment platform. Connect a GitHub repository for automatic builds and branch deployments, run serverless workers with SQLite databases, message queues, and durable workflows, and ship AI-powered apps through a built-in OpenAI-compatible gateway. Managed user auth and a local development emulator are included. Docs: https://docs.meetploy.com · Site: https://meetploy.com This file concatenates the full text of every documentation page below. # AI Skills URL: https://docs.meetploy.com/ai-skills # AI Skills Ploy provides agent skills that give AI coding assistants the knowledge to scaffold and build applications on Ploy. These skills work with Claude Code, Cursor, Windsurf, Codex, Aider, Cline, and other AI coding tools. ## Available Skills | Skill | Description | Use When | | ----------------- | ----------------------- | --------------------------------------------- | | `worker-basic` | Basic Cloudflare Worker | Building APIs, webhooks, serverless functions | | `nextjs-app` | Next.js application | Full-stack web apps with SSR | | `db-drizzle` | Database with Drizzle | Adding persistence to workers | | `nextjs-db` | Next.js with database | Full-stack apps with data | | `queue-handler` | Message queues | Background jobs, async processing | | `workflow` | Durable workflows | Multi-step processes, sagas | | `start-framework` | Ploy Start framework | Type-safe REST APIs | ## Quick Install The easiest way to install skills is using [`add-skill`](https://github.com/anthropics/add-skill), which works with any AI coding tool: ```bash npx add-skill meetploy/ploy ``` This will prompt you to: 1. Select which skills to install 2. Choose your AI tool (Claude Code, Cursor, Windsurf, etc.) 3. Automatically install to the correct location ### Install specific skills ```bash # Install only the skills you need npx add-skill meetploy/ploy/worker-basic npx add-skill meetploy/ploy/start-framework npx add-skill meetploy/ploy/db-drizzle ``` ## Manual Installation **Claude Code** ```bash git clone https://github.com/meetploy/ploy.git /tmp/ploy mkdir -p ~/.claude/skills cp -r /tmp/ploy/skills/* ~/.claude/skills/ ``` **Cursor** Copy to your Cursor skills directory: ```bash mkdir -p ~/.cursor/skills cp -r /path/to/ploy/skills/* ~/.cursor/skills/ ``` Or add to `.cursorrules` in your project: ``` Include skills from: ./skills/ ``` **Windsurf** Add to `.windsurfrules`: ```yaml skills: - path: ./skills/worker-basic/SKILL.md - path: ./skills/start-framework/SKILL.md # Add more as needed ``` **Other Tools** For Codex, Aider, Cline, or any tool with custom instructions, add the contents of the relevant SKILL.md files to your system prompt or project context. ## Usage Examples Once skills are installed, ask your AI assistant: ### Create a Worker > "Create a new Ploy worker with health check and JSON API endpoints" The AI will use the `worker-basic` skill to scaffold: ```typescript export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/health") { return Response.json({ status: "ok" }); } if (url.pathname === "/api/data") { return Response.json({ message: "Hello!" }); } return new Response("Not Found", { status: 404 }); }, } satisfies Ploy; ``` ### Add Database > "Add a users table with CRUD operations" The AI will use the `db-drizzle` skill to add: ```yaml # ploy.yaml db: DB: default ``` ```typescript // schema.ts export const users = sqliteTable("users", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), email: text("email").notNull().unique(), }); ``` ### Build a REST API > "Create a REST API for managing products with Ploy Start" The AI will use the `start-framework` skill: ```typescript import { ploy, withDrizzle, z } from "@meetploy/start"; const worker = ploy() .state(withDrizzle("DB", schema)) .openapi({ path: "/docs", info: { title: "Products API", version: "1.0.0" } }) .get( "/products", { response: z.object({ products: z.array(productSchema) }), }, async (ctx) => { const products = await ctx.state.db.select().from(schema.products); return { products }; }, ) .post( "/products", { body: z.object({ name: z.string(), price: z.number() }), response: z.object({ product: productSchema }), }, async (ctx) => { const [product] = await ctx.state.db .insert(schema.products) .values(ctx.body) .returning(); return { product }; }, ) .build(); ``` ### Add Background Processing > "Add a queue for processing image uploads" The AI will use the `queue-handler` skill: ```yaml # ploy.yaml queue: UPLOADS: image-uploads ``` ```typescript export default { async fetch(request, env) { const { messageId } = await env.UPLOADS.send({ type: "process-image", imageUrl: "...", }); return Response.json({ queued: true, messageId }); }, async message(event) { await processImage(event.payload); }, } satisfies Ploy; ``` ### Create a Workflow > "Build an order processing workflow with payment and shipping" The AI will use the `workflow` skill: ```typescript workflows: { order_processing: { handler: async ({ input, step, log }) => { log("Processing order", { orderId: input.orderId }); const payment = await step.run("charge", async () => { return await chargeCustomer(input.amount); }); const shipment = await step.run("ship", async () => { return await createShipment(input.address); }); await step.sleep(24 * 60 * 60 * 1000); // 24 hours await step.run("followup", async () => { await sendFollowupEmail(input.email); }); return { paymentId: payment.id, trackingNumber: shipment.tracking }; }, }, } ``` ## Skill Architecture Each skill is a markdown file with: ```yaml --- name: skill-name description: Brief description for selection --- # Skill Name Instructions for the AI on how to implement this pattern... ``` Skills include: * Project structure * Configuration files (ploy.yaml, package.json, tsconfig.json) * Code examples with TypeScript * Best practices and patterns * Common use cases ## Creating Custom Skills Add your own skills by creating markdown files in `skills/your-skill/SKILL.md`: ```yaml --- name: my-custom-skill description: Custom pattern for my project --- # My Custom Skill When asked to implement [pattern], follow these steps... ``` ## Repository Skills are maintained in the Ploy repository: [github.com/meetploy/ploy](https://github.com/meetploy/ploy) ``` ploy/ └── skills/ ├── README.md ├── worker-basic/ │ └── SKILL.md ├── nextjs-app/ │ └── SKILL.md ├── db-drizzle/ │ └── SKILL.md ├── nextjs-db/ │ └── SKILL.md ├── queue-handler/ │ └── SKILL.md ├── workflow/ │ └── SKILL.md └── start-framework/ └── SKILL.md ``` ## Troubleshooting ### Skill Not Recognized Ensure the skill files are in the correct location for your tool: * Claude Code: `~/.claude/skills/` or `.claude/skills/` in project * Cursor: `~/.cursor/skills/` or referenced in `.cursorrules` ### AI Not Following Instructions Try being more specific in your request: * "Use the worker-basic skill to create a new API" * "Following the db-drizzle skill, add a users table" ### Missing Dependencies After scaffolding, run: ```bash pnpm install pnpm types # Generate env.d.ts ``` # Configuration URL: https://docs.meetploy.com/configuration # Configuration Ploy uses a `ploy.yaml` file in the root of your repository to configure build settings and deployment options. This file provides fine-grained control over how your project is built and deployed. *** ## Quick Start Create a `ploy.yaml` file in your repository root: ```yaml kind: static build: npm run build out: dist ``` Commit and push the file to trigger a deployment with your custom configuration. *** ## Configuration Options ### `kind` **Type:** `"static" | "nextjs" | "dynamic" | "worker"` **Required:** No (auto-detected if omitted) Specifies the project type: * **`static`**: Static site generation (React, Vue, Angular, HTML, etc.) * **`nextjs`**: Next.js applications with server-side rendering support * **`dynamic`**: Worker-based applications and full-stack runtimes * **`worker`**: Alias for `dynamic` ```yaml kind: static ``` If `kind` is set to `static`, the `build` command is **required**. **Example - Static React App:** ```yaml kind: static build: npm run build out: dist ``` **Example - Next.js App:** ```yaml kind: nextjs build: npm run build out: .next ``` **Example - Cloudflare Vite / TanStack Start App:** ```yaml kind: dynamic build: pnpm build out: dist ``` *** ### `build` **Type:** `string` **Required:** Yes (when your project needs a build step) The command to run to build your project. This is typically a script defined in your `package.json`. ```yaml build: npm run build ``` **Common examples:** ```yaml # npm build: npm run build # pnpm build: pnpm build # yarn build: yarn build # Custom script build: npm run build:production ``` The `build` command is required for static apps and most dynamic apps, including Vite, TanStack Start, and worker bundles built during deployment. *** ### `assets` **Type:** `object` **Required:** No Configures uploaded static assets for dynamic projects such as Cloudflare Vite apps. Set static asset behavior here in `ploy.yaml`. ```yaml assets: binding: ASSETS not_found_handling: single-page-application run_worker_first: - /api/* - "!/api/docs/*" ``` **Fields:** * `binding`: Optional binding name exposed to your worker. Must be uppercase with underscores. * `not_found_handling`: One of `none`, `404-page`, or `single-page-application`. * `run_worker_first`: Either `true`, `false`, or an array of route patterns. Use `run_worker_first` when your worker should handle some requests before the asset layer. In the example above, `/api/*` stays worker-first, while `/api/docs/*` is served from static assets. *** ### `out` **Type:** `string` **Required:** No (auto-detected if omitted) **Default:** Framework-specific (e.g., `dist`, `build`, `.next`) The relative path to the output directory containing your built files. ```yaml out: dist ``` **Validation rules:** * Must be a **relative path** (not absolute) * Cannot start with `/` * Cannot contain `..` (path traversal prevention) * Trailing slashes are automatically removed **Common output directories:** | Framework | Default Output | | ---------------- | ------------------- | | Vite | `dist` | | Create React App | `build` | | Next.js | `.next` | | Angular | `dist/project-name` | | SvelteKit | `build` | | Astro | `dist` | **Example:** ```yaml kind: static build: npm run build out: dist ``` **Invalid paths:** - `out: /dist` ❌ (absolute path) - `out: ../dist` ❌ (path traversal) - `out: dist/` ✅ (trailing slash auto-removed) *** ### `base` **Type:** `string` **Required:** No **Default:** Repository root The base directory where your project is located within the repository. This is useful for monorepos where your deployable project is in a subdirectory. ```yaml base: apps/web ``` **Validation rules:** * Must be a **relative path** (not absolute) * Cannot start with `/` * Cannot contain `..` (path traversal prevention) * Trailing slashes are automatically removed **Monorepo example:** ``` my-monorepo/ ├── apps/ │ ├── web/ ← Your Next.js app │ └── api/ ├── packages/ │ └── ui/ └── ploy.yaml ``` ```yaml kind: nextjs base: apps/web build: npm run build out: .next ``` When using `base`, all paths (like `out`) are relative to the base directory, not the repository root. *** ### Monorepos Ploy detects monorepos automatically — there is no `monorepo` field. A repository is treated as a monorepo when it contains workspace indicators (`pnpm-workspace.yaml`, `package.json` with `workspaces`, `turbo.json`, etc.) **or** when more than one `ploy.yaml` is present in the repository. When a monorepo is detected and a project has a `base` field, Ploy installs dependencies from the repository root (so workspace dependencies resolve) and runs the build command from `base`. ``` my-monorepo/ ├── apps/ │ ├── web/ │ │ └── ploy.yaml ← Config for web app │ └── docs/ │ └── ploy.yaml ← Config for docs app ├── packages/ │ └── ui/ └── package.json ``` **`apps/web/ploy.yaml`:** ```yaml kind: nextjs base: apps/web build: pnpm build --filter web ``` **`apps/docs/ploy.yaml`:** ```yaml kind: nextjs base: apps/docs build: pnpm build --filter docs ``` Each app's `ploy.yaml` is a complete, self-contained configuration — it is not merged with any root config. Include all required fields in each file. To exclude directories that happen to contain a `ploy.yaml` (e.g., `examples/`, `templates/`, fixtures) from monorepo detection, add a `ploy-workspace.yaml` at the repository root: ```yaml # ploy-workspace.yaml exclude: - examples/** - templates/** ``` `node_modules/` is always excluded. *** ### `agentSDK` **Type:** `boolean` **Required:** No **Default:** `false` Enables the [Agent SDK](https://docs.meetploy.com/agent-sdk) bindings. When set to `true`, Ploy automatically configures all bindings required by `@meetploy/agent-sdk`: * `ai: true` (AI gateway) * `state: { PLOY_AGENT_STATE: ploy_agent_state }` (durable key-value storage) * `fs: { PLOY_AGENT_FILES: ploy_agent_files }` (file storage) * `workflow: { PLOY_AGENT_WORKFLOW: ploy_agent_run }` (durable workflow) * `timer: { PLOY_AGENT_SCHEDULER: ploy_agent_scheduler }` (scheduled tasks) ```yaml kind: worker agentSDK: true ``` You can still add extra bindings alongside `agentSDK: true` — they will be merged with the defaults. Running `ploy dev` will warn you if `@meetploy/agent-sdk` is installed but `agentSDK: true` is not set. Running `ploy types` will automatically add it. *** ### `skew_protection` **Type:** `boolean` **Required:** No **Default:** `true` Controls [Skew Protection](https://docs.meetploy.com/features/skew-protection), which pins a client's framework-managed requests to the deployment that served its page. Enabled by default; set to `false` to opt out. ```yaml kind: nextjs skew_protection: false ``` *** ## Complete Examples ### Static Vite + React App ```yaml kind: static build: npm run build out: dist ``` ### Next.js App ```yaml kind: nextjs build: pnpm build out: .next ``` ### Monorepo with Turborepo Project structure: ``` my-app/ ├── apps/ │ ├── web/ # Next.js frontend │ └── docs/ # Documentation site ├── packages/ │ └── ui/ # Shared UI components ├── package.json # Root with workspaces ├── turbo.json └── ploy.yaml ``` `ploy.yaml` for deploying the `web` app: ```yaml kind: nextjs base: apps/web build: npm run build out: .next ``` ### Custom Output Directory ```yaml kind: static build: npm run build:prod out: public/dist ``` ### SPA with Custom Base Path ```yaml kind: static build: npm run build out: build base: packages/client ``` *** ## Configuration Priority Ploy uses the following priority order for configuration: 1. **`ploy.yaml`** (highest priority) - Explicit configuration file 2. **Dashboard settings** - Manual overrides in project settings 3. **Auto-detection** (lowest priority) - Framework detection Settings in `ploy.yaml` will **override** dashboard settings and auto-detection. This ensures your repository configuration is the source of truth. *** ## Validation Errors Common validation errors and how to fix them: ### "Build command is required when kind is 'static'" ```yaml # ❌ Missing build command kind: static out: dist # ✅ Fixed kind: static build: npm run build out: dist ``` ### "Out path must be relative, not absolute" ```yaml # ❌ Absolute path kind: static build: npm run build out: /dist # ✅ Fixed - relative path kind: static build: npm run build out: dist ``` ### "Out path cannot contain '..'" ```yaml # ❌ Path traversal kind: static build: npm run build out: ../dist # ✅ Fixed kind: static build: npm run build out: dist ``` ### "Base path must be relative, not absolute" ```yaml # ❌ Absolute path kind: static base: /apps/web build: npm run build # ✅ Fixed kind: static base: apps/web build: npm run build ``` *** ## Best Practices 1. **Commit `ploy.yaml` to version control** - Keep configuration in sync with code 2. **Use `kind: static` for most frameworks** - Only use `nextjs` for Next.js apps requiring SSR 3. **Use relative paths** - Never use absolute paths or `..` for security 4. **Match your framework's output directory** - Check your framework's documentation for the correct `out` path *** ## Troubleshooting ### My build is failing 1. Check that your `build` command works locally: `npm run build` 2. Verify the `out` directory exists after building 3. Review deployment logs for specific error messages ### My monorepo isn't building correctly 1. Verify your root `package.json` has workspace configuration (or a `pnpm-workspace.yaml` exists) 2. Check that the `base` path points to the correct app directory 3. Ensure build commands are run from the base directory 4. To exclude unrelated `ploy.yaml` files (e.g., in `examples/`), add a `ploy-workspace.yaml` with an `exclude:` list at the repo root ### Configuration changes aren't taking effect 1. Ensure `ploy.yaml` is committed to your repository 2. Push your changes to trigger a new deployment 3. Check deployment logs to see which configuration was used 4. Verify there are no YAML syntax errors in your file *** ## Next Steps * Review the [Quick Start Guide](https://docs.meetploy.com/quick-start) for deployment basics * Learn about [Self-Hosting](https://docs.meetploy.com/self-host) to deploy Ploy on your infrastructure * Check out [example projects](https://docs.meetploy.com/quick-start#8--example-projects) for inspiration # Introduction to Ploy URL: https://docs.meetploy.com/ Connect a GitHub repository and every push builds and deploys automatically, with its own URL for each branch. Your workers get SQLite databases, message queues, durable workflows, file storage, and managed user auth out of the box — with no servers to manage and nothing running when there is no traffic. ## Get started Getting started: https://docs.meetploy.com/cli (CLI), https://docs.meetploy.com/start (Ploy Start SDK), https://docs.meetploy.com/nextjs (Next.js), https://docs.meetploy.com/vite (Vite), https://docs.meetploy.com/self-host (self-hosting). ## Features All features are documented under https://docs.meetploy.com/features; each feature page is included in full in this file. ## AI tooling Ploy is built to be read and driven by AI agents as well as people. AI tooling: https://docs.meetploy.com/llms.txt (docs index for LLMs), https://docs.meetploy.com/llms-full.txt (this file), https://docs.meetploy.com/ai-skills (agent skills), and https://docs.meetploy.com/agent-sdk (Agent SDK). ## How deployments work 1. **Connect GitHub** — authenticate and pick the repositories Ploy may access. 2. **Configure the project** — set the build command and bindings in [`ploy.yaml`](https://docs.meetploy.com/configuration), and environment variables in the dashboard. 3. **Push** — every push builds and deploys; each branch gets its own URL. 4. **Monitor** — follow real-time build logs and deployment status in the dashboard. 5. **Roll back** — redeploy any previous deployment in one step. ## Hosted or self-hosted Ploy runs the same either way. Use the hosted platform at [meetploy.com](https://meetploy.com) to skip setup entirely, or [self-host](https://docs.meetploy.com/self-host) it so your code, environment variables, and databases never leave your own infrastructure. ## Next steps * [**CLI**](https://docs.meetploy.com/cli) — develop locally with `ploy dev` * [**Ploy Start**](https://docs.meetploy.com/start) — the type-safe framework for building workers * [**Configuration**](https://docs.meetploy.com/configuration) — every `ploy.yaml` option * [**Self-Hosting Quickstart**](https://docs.meetploy.com/self-host-quickstart) — get an instance running in minutes # Self-Hosting Quickstart URL: https://docs.meetploy.com/self-host-quickstart # 🚀 Quickstart Welcome to **Ploy**—a self-hostable serverless deployment platform that lets you deploy web applications with automatic builds, GitHub integration, and continuous deployment. > **TL;DR** — Connect your GitHub repository, push your code, and Ploy handles the rest. *** ## 0 · Quick Install (Self-Hosted) Get Ploy running on your server in minutes with our automated install script: ```bash # Simple mode (unified container - good for testing/development) curl -fsSL https://raw.githubusercontent.com/meetploy/ploy/main/scripts/install.sh | sudo bash -s -- simple # Production mode (split containers - recommended for production) curl -fsSL https://raw.githubusercontent.com/meetploy/ploy/main/scripts/install.sh | sudo bash -s -- prod ``` ### What the install script does: 1. Detects your Linux distribution (Ubuntu, Debian, Fedora, CentOS, RHEL, Arch, openSUSE) 2. Installs Docker and Docker Compose if not already installed 3. Downloads the appropriate docker-compose configuration 4. Creates a sample `.env` file for configuration 5. Starts all Ploy services ### Installation modes: * **simple**: All services run in a single unified container. Best for testing, development, or small deployments. * **prod**: Each service runs in its own container. Better resource isolation and recommended for production use. ### After installation: 1. Edit `/opt/ploy/.env` with your configuration (especially GitHub OAuth credentials) 2. Restart services: `cd /opt/ploy && docker compose up -d` 3. Access the dashboard at `http://your-server-ip:3002` For more details, see the [Self-Hosting Guide](https://docs.meetploy.com/self-host). *** ## 1 · Sign in with GitHub 1. Visit the Ploy dashboard at [meetploy.com](https://meetploy.com) (or your self-hosted instance) 2. Click **Sign in with GitHub** 3. Authorize Ploy to access your repositories *** ## 2 · Create an organization and project 1. After signing in, **create a new organization** (or use an existing one) 2. Navigate to **Projects** and click **New Project** 3. Give your project a name (e.g., "my-app") *** ## 3 · Connect a GitHub repository 1. Click **Connect Repository** in your project 2. Install the Ploy GitHub App if prompted 3. Select the repository you want to deploy 4. Choose the branch to deploy (default: `main`) *** ## 4 · Configure build settings Ploy automatically detects most frameworks, but you can customize: ### Framework Detection Ploy automatically detects and configures: * **Next.js** - Static and server-side rendering * **React** - Static site generation with Vite or Create React App * **Vue** - Static sites with Vite * **Nuxt** - Static and server-side rendering * **Svelte/SvelteKit** - Static and server-side rendering * **Astro** - Static site generation * **Angular** - Static site generation * **Static HTML** - Plain HTML, CSS, and JavaScript ### Using ploy.yaml (Recommended) The recommended way to configure your project is by adding a `ploy.yaml` file to the root of your repository: ```yaml # ploy.yaml kind: static build: npm run build out: dist ``` Basic configuration options: * **kind**: Project type (`static` or `nextjs`) * **build**: Build command (e.g., `npm run build`) * **out**: Output directory for built files (e.g., `dist`, `build`, `.next`) For advanced configuration options including monorepo and base path support, see the [Configuration](https://docs.meetploy.com/configuration) guide. ### Dashboard Configuration If needed, you can also override settings in the Ploy dashboard: ```bash # Build Command (optional - auto-detected) npm run build # Output Directory (optional - auto-detected) dist # Install Command (optional) npm install ``` ### Environment Variables Add environment variables for your build: 1. Go to **Project Settings** → **Environment Variables** 2. Add key-value pairs: * `API_URL=https://api.example.com` * `NEXT_PUBLIC_API_KEY=your-key-here` *** ## 5 · Deploy Once your repository is connected: 1. **Automatic Deployment**: Push to your configured branch 2. **Manual Deployment**: Click **Deploy** in the dashboard 3. **Monitor Progress**: Watch real-time build logs ```bash # Push to trigger deployment git add . git commit -m "Initial deployment" git push origin main ``` ### Deployment Process Ploy will: 1. Clone your repository 2. Install dependencies 3. Run the build command 4. Upload static assets 5. Generate a unique deployment URL *** ## 6 · Access your deployment After a successful deployment: 1. **View your app**: Click the deployment URL (e.g., `https://my-app-abc123.ploy.app`) 2. **Share with team**: Each deployment gets a unique URL 3. **Branch deployments**: Every branch gets its own URL for testing Example deployment URLs: ``` Production (main): https://my-app.ploy.app Branch (feature): https://my-app-feature-xyz.ploy.app PR Preview: https://my-app-pr-123.ploy.app ``` *** ## 7 · Advanced features ### Custom Domains Connect your own domain: 1. Go to **Project Settings** → **Domains** 2. Add your domain (e.g., `example.com`) 3. Configure DNS records as shown 4. Wait for SSL certificate provisioning ### Preview Deployments Every push to any branch creates a preview deployment: * Test features before merging to production * Share previews with team members or clients * Automatic cleanup when branches are deleted ### Build Logs Access detailed build logs: 1. Click on any deployment 2. View **Build Logs** tab 3. Debug failed deployments with full output ### Rollback Quickly revert to a previous deployment: 1. Go to **Deployments** history 2. Click on a previous successful deployment 3. Click **Promote to Production** *** ## 8 · Example Projects ### Next.js App ```bash # Create a new Next.js app npx create-next-app@latest my-nextjs-app cd my-nextjs-app # Initialize git and push to GitHub git init git add . git commit -m "Initial commit" git remote add origin https://github.com/yourusername/my-nextjs-app.git git push -u origin main ``` Then connect the repository in Ploy - it will auto-detect Next.js and deploy! ### Vite + React App ```bash # Create a new Vite app npm create vite@latest my-react-app -- --template react cd my-react-app # Initialize git and push to GitHub git init git add . git commit -m "Initial commit" git remote add origin https://github.com/yourusername/my-react-app.git git push -u origin main ``` Connect in Ploy and deploy automatically! ### Static HTML Site ```bash # Create a simple HTML site mkdir my-static-site cd my-static-site echo '

Hello Ploy!

' > index.html # Initialize git and push to GitHub git init git add . git commit -m "Initial commit" git remote add origin https://github.com/yourusername/my-static-site.git git push -u origin main ``` Connect in Ploy and deploy! *** ## 9 · FAQ **What frameworks are supported?** Ploy supports all major web frameworks including Next.js, React, Vue, Nuxt, Svelte, SvelteKit, Astro, Angular, and static HTML sites. Framework detection is automatic in most cases. **How much does it cost?** Ploy is open-source and free to self-host. For the hosted version, see our [Pricing page](https://meetploy.com#pricing). Self-hosting gives you complete cost control - you only pay for your infrastructure. **Can I use my own domain?** Yes! You can connect custom domains to any project. Ploy automatically provisions SSL certificates via Let's Encrypt. **What makes Ploy different from Vercel?**

Unlike Vercel, Ploy offers:

**How do I rollback a deployment?** Go to your project's Deployments page, find a previous successful deployment, and click "Promote to Production". Your app will immediately use that version. **Can I deploy multiple branches?** Yes! Every branch gets its own deployment URL. This is perfect for testing features before merging to production. **How do I view build logs?** Click on any deployment in your project's Deployments page, then view the Build Logs tab. You'll see real-time output from the build process. *** ## 10 · Next steps * Read the [Self-Hosting Guide](https://docs.meetploy.com/self-host) to deploy Ploy on your infrastructure * Check out our [GitHub repository](https://github.com/meetploy/ploy) for source code * Join our community for help and feature requests Happy deploying! ✨ # Self Host Ploy URL: https://docs.meetploy.com/self-host # Self Host Ploy Ploy is a self-hostable serverless deployment platform and Vercel alternative. This guide will help you deploy Ploy on your own infrastructure, giving you complete control over your deployments, code, and data. ## Prerequisites * **Docker** (latest version) and Docker Compose * **GitHub OAuth App** (for authentication) * **Domain** (optional, but recommended for production) * **2GB+ RAM** (4GB+ recommended for production) * **10GB+ disk space** (more for storing deployments) ## Quick Start The fastest way to get started is with Docker Compose: ```bash # Clone the repository git clone https://github.com/meetploy/ploy.git cd ploy # Copy environment variables cp .env.example .env # Edit .env with your configuration (see below) nano .env # Start all services docker compose up -d ``` After starting, access: * **Web Dashboard**: [http://localhost:3002](http://localhost:3002) * **Documentation**: [http://localhost:3005](http://localhost:3005) * **API**: [http://localhost:4002](http://localhost:4002) * **Handler** (serves deployments): [http://localhost:4001](http://localhost:4001) ## Option 1: Unified Docker Image (Simplest) This option uses a single Docker container with all services bundled together. Perfect for testing or small deployments. ```bash # Run the unified container docker run -d \ --name ploy \ --restart unless-stopped \ -p 3002:3002 \ -p 3005:3005 \ -p 4001:4001 \ -p 4002:4002 \ -v ~/ploy_data:/var/lib/postgresql/data \ -v ~/ploy_fs:/app/fs \ -e AUTH_SECRET=your-secret-key-here \ -e GITHUB_CLIENT_ID=your-github-client-id \ -e GITHUB_CLIENT_SECRET=your-github-client-secret \ ghcr.io/meetploy/ploy-unified:latest ``` Note: Replace `latest` with a specific version from [releases](https://github.com/meetploy/ploy/releases). ### Using Docker Compose (Unified) ```bash # Download the compose file curl -O https://raw.githubusercontent.com/meetploy/ploy/main/infra/docker-compose.unified.yml # Copy and configure environment curl -O https://raw.githubusercontent.com/meetploy/ploy/main/.env.example cp .env.example .env # Edit .env with your configuration # Start the service docker compose -f docker-compose.unified.yml up -d ``` ## Option 2: Separate Services (Recommended for Production) This option runs each service in its own container, providing better scalability and resource management. ```bash # Clone the repository git clone https://github.com/meetploy/ploy.git cd ploy # Configure environment cp .env.example .env # Edit .env with your configuration # Start all services docker compose -f infra/docker-compose.split.yml up -d ``` ### Services in Split Mode The split deployment includes: * **postgres** - PostgreSQL database * **redis** - Redis cache and session store * **api** - REST API server * **ui** - Web dashboard (Next.js) * **builder** - Background job processor * **handler** - Deployment handler (serves deployed apps) * **docs** - Documentation site ## Required Configuration ### 1. GitHub OAuth App Create a GitHub OAuth App for authentication: 1. Go to GitHub Settings → Developer settings → OAuth Apps 2. Click **New OAuth App** 3. Fill in: * **Application name**: Ploy * **Homepage URL**: `http://localhost:3002` (or your domain) * **Authorization callback URL**: `http://localhost:3002/api/auth/callback/github` 4. Click **Register application** 5. Copy the **Client ID** and generate a **Client Secret** ### 2. GitHub App (for Repository Access) Create a GitHub App for repository access and webhooks: 1. Go to GitHub Settings → Developer settings → GitHub Apps 2. Click **New GitHub App** 3. Fill in: * **GitHub App name**: Ploy Deployments * **Homepage URL**: `http://localhost:3002` * **Webhook URL**: `http://localhost:4002/webhooks/github` * **Webhook secret**: Generate a random string 4. Set permissions: * **Repository permissions**: * Contents: Read * Metadata: Read * Webhooks: Read & Write 5. Subscribe to events: * Push * Repository 6. Click **Create GitHub App** 7. Generate and download a private key 8. Note the **App ID** ### 3. Environment Variables Edit your `.env` file with the following required variables: ```bash # Database POSTGRES_PASSWORD=your_secure_password_here DATABASE_URL=postgresql://postgres:your_secure_password_here@postgres:5432/ploy # Redis REDIS_URL=redis://redis:6379 # Authentication AUTH_SECRET=your-secret-key-here BETTER_AUTH_URL=http://localhost:3002 # GitHub OAuth (for user authentication) GITHUB_CLIENT_ID=your-github-oauth-client-id GITHUB_CLIENT_SECRET=your-github-oauth-client-secret # GitHub App (for repository access) GITHUB_APP_ID=your-github-app-id GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" GITHUB_WEBHOOK_SECRET=your-webhook-secret # API URLs NEXT_PUBLIC_API_URL=http://localhost:4002 API_HANDLER_URL=http://handler:4001 # Deployment Storage FS_PATH=/app/fs ``` ### 4. Generate Secrets Generate secure random strings for secrets: ```bash # Generate AUTH_SECRET openssl rand -hex 32 # Generate GITHUB_WEBHOOK_SECRET openssl rand -hex 32 ``` ## Database Storage Modes Ploy supports two storage modes for project databases: * `basic` is the default. It keeps each project database as a local SQLite file inside `DB_STORAGE_DIR`. * `turso-cloud` is opt-in. It provisions databases in Turso Cloud and uses Turso-managed replication and point-in-time restore. ### Basic Mode `basic` matches the simplest self-hosted setup and is what the bundled compose files use by default. Required environment variables: ```bash DB_STORAGE_MODE=basic DB_STORAGE_DIR=/data/db-storage ``` Use this mode when: * you are running Ploy on a single machine * you want the fewest moving pieces * you are okay with local SQLite files and handling their durability yourself ### Turso Cloud Mode Set `DB_STORAGE_MODE=turso-cloud` to place worker databases in Turso Cloud. When enabled, Ploy creates Turso databases on demand and routes all worker database traffic there. Required environment variables: ```bash DB_STORAGE_MODE=turso-cloud TURSO_ORGANIZATION=your-turso-organization TURSO_GROUP_NAME=default TURSO_PRIMARY_LOCATION=ord TURSO_DATABASE_PREFIX=ploy TURSO_API_TOKEN=your_turso_platform_api_token TURSO_GROUP_AUTH_TOKEN=your_turso_group_auth_token ``` Optional: ```bash TURSO_API_URL=https://api.turso.tech TURSO_PITR_RETENTION_DAYS=30 ``` Use this mode when: * you want managed replicas and failover from Turso * you want Turso-backed point-in-time recovery * you do not want to run your own libSQL cluster ### Compose Defaults The compose files in this repository default to: ```bash DB_STORAGE_MODE=basic DB_STORAGE_DIR=/data/db-storage ``` The older libSQL containers remain available only behind an optional `legacy-libsql` compose profile and are no longer started by default. ## Production Configuration For production deployments, consider these additional settings: ### Domain Setup 1. Point your domain to your server's IP 2. Update environment variables: ```bash # Frontend URL BETTER_AUTH_URL=https://ploy.yourdomain.com NEXT_PUBLIC_API_URL=https://api.yourdomain.com # Update GitHub OAuth callback # In GitHub OAuth App settings, set callback to: # https://ploy.yourdomain.com/api/auth/callback/github # Update GitHub App webhook URL # In GitHub App settings, set webhook to: # https://api.yourdomain.com/webhooks/github ``` ### SSL/TLS Certificates Use a reverse proxy like Nginx or Caddy for SSL: #### Caddy Example Create a `Caddyfile`: ``` ploy.yourdomain.com { reverse_proxy localhost:3002 } api.yourdomain.com { reverse_proxy localhost:4002 } *.ploy-deployments.yourdomain.com { reverse_proxy localhost:4001 } ``` Run Caddy: ```bash caddy run ``` ### Resource Limits Add resource limits to your `docker-compose.yml`: ```yaml services: api: deploy: resources: limits: cpus: "1" memory: 1G reservations: cpus: "0.5" memory: 512M ``` ### Monitoring Enable logging and monitoring: ```bash # View logs for all services docker compose logs -f # View logs for specific service docker compose logs -f api # View resource usage docker stats ``` ## Management Commands ### Docker Compose (Unified) ```bash # Start services docker compose -f docker-compose.unified.yml up -d # View logs docker compose -f docker-compose.unified.yml logs -f # Restart services docker compose -f docker-compose.unified.yml restart # Stop services docker compose -f docker-compose.unified.yml down # Stop and remove volumes docker compose -f docker-compose.unified.yml down -v ``` ### Docker Compose (Split) ```bash # Start all services docker compose -f infra/docker-compose.split.yml up -d # Start specific service docker compose -f infra/docker-compose.split.yml up -d api # View logs docker compose -f infra/docker-compose.split.yml logs -f # Restart services docker compose -f infra/docker-compose.split.yml restart # Stop services docker compose -f infra/docker-compose.split.yml down ``` ### Database Operations ```bash # Backup database docker exec ploy-postgres pg_dump -U postgres ploy > backup.sql # Restore database docker exec -i ploy-postgres psql -U postgres ploy < backup.sql # Access database shell docker exec -it ploy-postgres psql -U postgres ploy ``` ## Troubleshooting ### Common Issues **Services won't start:** ```bash # Check logs docker compose logs -f # Verify environment variables docker compose config # Ensure ports are not in use lsof -i :3002 lsof -i :4002 ``` **Database connection errors:** ```bash # Check PostgreSQL is running docker compose ps postgres # Verify DATABASE_URL in .env # Ensure POSTGRES_PASSWORD matches in DATABASE_URL ``` **GitHub webhook not working:** 1. Check webhook URL is publicly accessible 2. Verify GITHUB\_WEBHOOK\_SECRET matches GitHub App 3. Check API logs: `docker compose logs -f api` 4. Test webhook delivery in GitHub App settings **Deployments failing:** ```bash # Check worker logs docker compose logs -f builder # Check handler logs docker compose logs -f handler # Verify FS_PATH volume is writable docker exec ploy-builder ls -la /app/fs ``` ### Debug Mode Enable debug logging: ```bash # Add to .env LOG_LEVEL=debug # Restart services docker compose restart ``` ## Updating Ploy To update to the latest version: ```bash # Pull latest changes git pull origin main # Pull latest images docker compose pull # Restart services docker compose down docker compose up -d # Check logs for any issues docker compose logs -f ``` ## Build from Source To build Ploy from source: ```bash # Clone repository git clone https://github.com/meetploy/ploy.git cd ploy # Install dependencies pnpm install # Build all packages pnpm build # Start services with local build docker compose -f infra/docker-compose.local.yml up -d ``` ## Performance Tuning ### PostgreSQL Optimize PostgreSQL for your workload: ```bash # Add to docker-compose.yml postgres service environment: - POSTGRES_SHARED_BUFFERS=256MB - POSTGRES_EFFECTIVE_CACHE_SIZE=1GB - POSTGRES_MAX_CONNECTIONS=100 ``` ### Redis Configure Redis memory limits: ```bash # Add to docker-compose.yml redis service command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru ``` ### Worker Concurrency Adjust worker concurrency: ```bash # Add to .env WORKER_CONCURRENCY=5 ``` ## Security Best Practices 1. **Use strong passwords** for database and secrets 2. **Enable HTTPS** in production with valid SSL certificates 3. **Keep secrets secure** - never commit `.env` files 4. **Regular backups** of database and deployment files 5. **Update regularly** to get security patches 6. **Firewall rules** - only expose necessary ports 7. **Limit GitHub App permissions** to minimum required ## Next Steps Once Ploy is running: 1. **Sign in** at [http://localhost:3002](http://localhost:3002) (or your domain) 2. **Create an organization** and project 3. **Connect a repository** and deploy your first app 4. **Configure custom domains** for production deployments 5. **Explore the API** at [http://localhost:4002/docs](http://localhost:4002/docs) ## Support * **Documentation**: [https://docs.meetploy.com](https://docs.meetploy.com) * **GitHub Issues**: [https://github.com/meetploy/ploy/issues](https://github.com/meetploy/ploy/issues) * **Community**: Join our Discord or discussions Happy self-hosting! ✨ # TanStack Start URL: https://docs.meetploy.com/tanstack-start # TanStack Start [TanStack Start](https://tanstack.com/start) apps run on Ploy through the same [`ploy()`](https://docs.meetploy.com/vite) plugin used for other Vite apps. You get TanStack's file-based routing, server routes, and build-time prerendering, served on the Ploy Workers runtime — with bindings and configuration driven entirely from `ploy.yaml`. TanStack Start requires the **latest Vite**. Pin `vite` to the current major (Vite 8+) in your project; older majors are not supported by the TanStack Start adapter. ## Project setup A TanStack Start project needs three pieces: a `package.json` that calls Ploy, a `vite.config.ts` that combines `ploy()` with the TanStack Start plugin, and a `ploy.yaml`. ```json title="package.json" { "scripts": { "build": "ploy vite build && tsc --noEmit", "types": "ploy types -o env.d.ts" }, "dependencies": { "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.0", "react": "^19", "react-dom": "^19" }, "devDependencies": { "@meetploy/cli": "latest", "@meetploy/vite": "latest", "@vitejs/plugin-react": "^6", "vite": "^8" } } ``` ```typescript title="vite.config.ts" import { ploy } from "@meetploy/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ ...ploy({ viteEnvironment: { name: "ssr" } }), tanstackStart({ prerender: { enabled: true, autoSubfolderIndex: true, crawlLinks: false, failOnError: true, }, }), viteReact(), ], }); ``` ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist compatibility_date: "2026-03-17" compatibility_flags: - nodejs_compat ``` `ploy()` returns an array of plugins, so spread it into the `plugins` list. Any [plugin options](https://docs.meetploy.com/vite#configuration) you would pass to the underlying Vite integration (such as `viteEnvironment`) go directly to `ploy()`. When `@tanstack/react-start` is installed, Ploy automatically maps the worker entry to `@tanstack/react-start/server-entry` — there is no `worker/index.ts` to maintain. ## Routing TanStack Start uses file-based routing under `src/routes`. Pages render with React; server routes run on the worker. ```tsx title="src/routes/index.tsx" import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ component: HomePage, }); function HomePage() { return

Rendered at build time, served from the worker

; } ``` ### Server routes Add a `server` block to a route to handle requests on the worker — this is where API endpoints live. ```ts title="src/routes/api/hello.ts" import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/api/hello")({ server: { handlers: { GET: ({ pathname }) => Response.json({ message: "hello from worker", pathname }), }, }, }); ``` ## Prerendering The TanStack Start plugin can prerender routes to static HTML at build time while keeping the app on the worker runtime for everything else. Configure it through the `prerender` option in `vite.config.ts`: ```typescript tanstackStart({ prerender: { enabled: true, autoSubfolderIndex: true, crawlLinks: false, failOnError: true, }, }); ``` Prerendered routes are emitted as static files and served as assets; dynamic routes and server handlers continue to run on the worker. ## Bindings Add Ploy resources — databases, queues, auth, and more — to `ploy.yaml` exactly as you would for any other Vite app. `ploy()` injects them into the worker so they are available on `env` in your server routes, backed by the local Ploy dev environment during `ploy dev`. See the [Vite](https://docs.meetploy.com/vite#bindings) guide for the binding maps and [Features](https://docs.meetploy.com/features/db) for each resource. ```yaml title="ploy.yaml" db: DB: default ``` After changing bindings, regenerate types so `env.d.ts` stays current: ```bash ploy types -o env.d.ts ``` ## Running it ```bash # Dev server + local Ploy dashboard ploy dev # Production build ploy vite build ``` `ploy dev` auto-detects the TanStack Start project and starts the Vite dev server alongside the local Ploy dashboard. See [ploy dev](https://docs.meetploy.com/cli/dev) and [ploy vite](https://docs.meetploy.com/cli/vite) for the command reference. ## Example A complete, runnable project — prerendered routes, a server route, and worker APIs — lives in [examples/tanstack-start](https://github.com/meetploy/ploy/tree/main/examples/tanstack-start). # Vite URL: https://docs.meetploy.com/vite # Vite Ploy runs Vite apps — React SPAs that ship a worker, TanStack Start apps, and other worker + assets projects — directly from your `ploy.yaml`. You keep a single source of truth for bindings, assets, and compatibility settings, and Ploy handles the dev and build toolchain for you. Ploy ships the [`@meetploy/vite`](https://www.npmjs.com/package/@meetploy/vite) package. Add its `ploy()` plugin to your `vite.config.ts` and it wires bindings, assets, and compatibility settings from `ploy.yaml` into the Vite toolchain for both dev and build. ## How it works When you run `ploy dev` or `ploy vite`, Ploy: 1. **Detects a Ploy Vite project** — the project has a `vite.config.*` file **and** `@meetploy/vite` in its dependencies (or an ancestor's `package.json`, so monorepos work). 2. **Reads and validates `ploy.yaml`** and resolves any Agent SDK bindings. 3. **Builds the runtime configuration** from `ploy.yaml` — bindings, assets, and compatibility date/flags. 4. **Isolates the Miniflare registry** under a fresh temp directory per project, so multiple projects don't collide locally. 5. **Spawns Vite** (your local `node_modules/.bin/vite`, falling back to `npx vite`) with the generated config and Ploy environment variables wired in. For `ploy dev`, a local Ploy dashboard / mock server is also started on `port + 1000` and its URL is exposed to the worker via `PLOY_DASHBOARD_URL` and `PLOY_MOCK_SERVER_URL`. Keep your bindings and compatibility settings in `ploy.yaml` — it's the single source of truth for your project's configuration. ## Project setup A Ploy Vite project needs three pieces: a `package.json` that calls Ploy, a `vite.config.ts` using the `ploy()` plugin from `@meetploy/vite`, and a `ploy.yaml`. Install the plugin as a dev dependency: ```bash pnpm add -D @meetploy/vite ``` ```json title="package.json" { "scripts": { "dev": "ploy vite dev", "build": "tsc --noEmit && ploy vite build", "types": "ploy types -o env.d.ts" } } ``` ```typescript title="vite.config.ts" import { ploy } from "@meetploy/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), ...ploy()], }); ``` `ploy()` returns an array of plugins, so spread it into the `plugins` list. ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist compatibility_date: "2026-03-23" assets: binding: ASSETS not_found_handling: single-page-application run_worker_first: - /api/* - "!/api/docs/*" db: DB: default ``` For non-TanStack projects, Ploy looks for a worker entry at `worker/index.ts` (or the `.js` / `.mjs` variants). ## Configuration Everything the Vite toolchain needs is configured through `ploy.yaml`. ### Project kind Use `kind: dynamic` for Vite apps that ship a worker alongside static assets. ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist ``` | Field | Description | | ------- | ----------------------------------------- | | `kind` | `dynamic` for Vite worker + assets apps | | `build` | Build command Ploy runs to produce `out` | | `out` | Output directory the Vite build writes to | ### Compatibility Set the Workers runtime compatibility date and flags here in `ploy.yaml`. ```yaml title="ploy.yaml" compatibility_date: "2026-03-23" compatibility_flags: - nodejs_compat ``` ### Assets The top-level `assets` key controls static asset behavior for dynamic Vite deployments. ```yaml title="ploy.yaml" assets: binding: ASSETS not_found_handling: single-page-application run_worker_first: - /api/* - "!/api/docs/*" ``` * `binding` exposes the asset fetcher to your worker as `env.ASSETS` * `not_found_handling: single-page-application` enables SPA fallback so client routes resolve to `index.html` * `run_worker_first` lets API routes (or other patterns) bypass the asset layer and hit your worker first; prefix a pattern with `!` to exclude it ### Bindings Add Ploy resources — databases, queues, auth, and more — with the same binding maps used by workers. They're available on `env` in your worker. ```yaml title="ploy.yaml" db: DB: default queue: TASKS: tasks ``` After changing bindings, regenerate types so `env.d.ts` stays current: ```bash ploy types -o env.d.ts ``` ### Dev server port and host You can pin the local dev port and host per project. CLI flags (`-p` / `--port`, `-h` / `--host`) take precedence. ```yaml title="ploy.yaml" dev: port: 3000 host: localhost ``` ## Running it There are two ways to drive the Vite integration: * **`ploy dev`** — auto-detects the project type and, for Ploy Vite projects, starts the Vite dev server plus the local Ploy dashboard. This is the usual command during development. See [ploy dev](https://docs.meetploy.com/cli/dev). * **`ploy vite `** — runs the underlying Vite command directly with the `ploy.yaml`-managed config. Use this in `package.json` scripts and CI. See [ploy vite](https://docs.meetploy.com/cli/vite). ```bash # Auto-detected dev server + dashboard ploy dev # Explicit Vite dev / build with ploy.yaml config ploy vite dev ploy vite build ``` Any extra arguments after `ploy vite ` are forwarded to Vite. To pass Vite's own `--config`, forward it explicitly: ```bash ploy vite build -- --config vite.config.custom.ts ``` ## TanStack Start TanStack Start apps work the same way — spread `ploy()` alongside the TanStack Start plugin. See the dedicated [TanStack Start](https://docs.meetploy.com/tanstack-start) guide for routing, server routes, and prerendering. ## Notes * Use the `ploy()` plugin from `@meetploy/vite` and let Ploy manage your configuration from `ploy.yaml`. * Keep compatibility settings and bindings in `ploy.yaml`. * For plain workers use [`ploy dev`](https://docs.meetploy.com/cli/dev) / [`ploy build`](https://docs.meetploy.com/cli); for Next.js use `ploy dev` with [`@meetploy/nextjs`](https://docs.meetploy.com/nextjs). # Commands URL: https://docs.meetploy.com/agent-sdk/commands # Commands The Agent SDK provides built-in commands accessible via `agent.commands` in your `handle` function. These let users manage their workspace, toggle debug mode, and install MCP servers. ## Using Commands Commands are available on the `AgentContext` passed to your `handle` function. Route them however fits your platform: ```typescript handle: async (req, env, agent) => { const body = (await req.json()) as { userId: string; chatId: string; text: string; }; // Check for command prefix if (body.text.startsWith("/")) { const [command, ...args] = body.text.slice(1).split(" "); switch (command) { case "reset": return Response.json({ reply: await agent.commands.reset(body.userId, body.chatId), }); case "debug": return Response.json({ reply: await agent.commands.debug(body.userId, body.chatId), }); case "tools": return Response.json({ reply: await agent.commands.listTools(body.userId), }); case "install": return Response.json({ reply: await agent.commands.installMcp( body.userId, body.chatId, args[0], ), }); case "uninstall": return Response.json({ reply: await agent.commands.uninstallMcp(body.userId, args[0]), }); } } // Normal message handling // ... }; ``` ## Available Commands ### reset Deletes the user's workspace entirely, clearing conversation history, memory, and all settings. ```typescript const reply = await agent.commands.reset(userId, chatId); // "Context cleared. Starting fresh." ``` ### debug Toggles debug mode on the user's workspace. When enabled, internal processing details are visible. ```typescript const reply = await agent.commands.debug(userId, chatId); // "Debug mode enabled. You'll see internal processing details." // or // "Debug mode disabled." ``` ### listTools Lists all available tools -- built-in, custom, and MCP server tools. ```typescript const reply = await agent.commands.listTools(userId); // "Available tools:\n- memory_set\n- memory_get\n- ..." ``` ### installMcp Installs an MCP server by fetching its manifest from a URL. The manifest must expose a `/manifest` endpoint returning `{ name: string, tools: Array<{ name, description, parameters }> }`. ```typescript const reply = await agent.commands.installMcp( userId, chatId, "https://mcp.example.com", ); // 'Installed MCP server "weather" with tools: get_forecast, get_alerts' ``` ### uninstallMcp Removes an installed MCP server by name. ```typescript const reply = await agent.commands.uninstallMcp(userId, "weather"); // 'Uninstalled MCP server "weather".' ``` ## AgentCommands Interface ```typescript interface AgentCommands { reset: (userId: string, chatId: string) => Promise; debug: (userId: string, chatId: string) => Promise; installMcp: (userId: string, chatId: string, url: string) => Promise; uninstallMcp: (userId: string, name: string) => Promise; listTools: (userId: string) => Promise; } ``` Commands return plain strings. It's up to your `handle` function to send the reply back to the user through your platform's messaging API. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- Access commands via `agent.commands` in your handler * [Tools](https://docs.meetploy.com/agent-sdk/tools) -- The tools listed by `/tools` * [State & Workspaces](https://docs.meetploy.com/agent-sdk/state) -- What `/reset` deletes # createAgent() URL: https://docs.meetploy.com/agent-sdk/create-agent # createAgent() `createAgent(config)` is the main entry point of the Agent SDK. It takes an `AgentConfig` and returns a `Ploy` export that handles fetch requests, timer events, and the durable agent workflow. ## AgentConfig ```typescript import { createAgent } from "@meetploy/agent-sdk"; export default createAgent({ // Required systemPrompt: "You are a helpful assistant.", handle: myHandler, // Optional model: "auto", contextCompression: { enabled: true, maxTokens: 2000, summaryModel: "gpt-4o-mini", preserveRecentMessages: 12, }, streaming: { enabled: false }, features: { memory: true, artifacts: true, scheduling: true, mcp: true }, tools: [myTool], hooks: { onRunPartial(ctx, partial) { /* ... */ }, onRunComplete(ctx, response) { /* ... */ }, }, maxSteps: 30, }); ``` ### systemPrompt The system prompt sent to the AI on every turn. Can be a static string or a function that returns a per-user prompt: ```typescript // Static systemPrompt: "You are a helpful assistant."; // Dynamic per-user systemPrompt: async (userId) => { const prefs = await loadPreferences(userId); return `You are a helpful assistant. Language: ${prefs.language}.`; }; ``` The SDK automatically appends the user's stored memories to the system prompt. When context compression is enabled, it also appends the running conversation summary. ### handle Your custom request handler. This is where you parse incoming requests (HTTP, webhooks, etc.), call `agent.handleMessage()`, and return a response: ```typescript async function handle(req: Request, env: PloyEnv, agent: AgentContext) { const body = (await req.json()) as { userId: string; chatId: string; text: string; }; const messenger = new MyMessenger(); await agent.handleMessage({ userId: body.userId, chatId: body.chatId, platform: "http", text: body.text, messenger, }); return Response.json({ ok: true }); } ``` The `AgentContext` passed to your handler provides: | Property | Type | Description | | --------------- | --------------------------- | -------------------------------------------- | | `handleMessage` | `(params) => Promise` | Enqueue a user message for processing | | `commands` | `AgentCommands` | Built-in commands (reset, debug, tools, MCP) | | `state` | `StateManager` | Direct access to the state binding | | `env` | `PloyEnv` | The full Ploy environment bindings | ### model The model name passed to the AI gateway. Defaults to `"auto"`. ```typescript model: "gpt-4o"; ``` ### contextCompression Enable summary-based context compression for long-running conversations: ```typescript contextCompression: { enabled: true, maxTokens: 2000, summaryModel: "gpt-4o-mini", preserveRecentMessages: 12, } ``` Options: | Property | Type | Description | | ------------------------ | --------- | ------------------------------------------------------------------------------------------------ | | `enabled` | `boolean` | Enables summary-based compression. Disabled by default. | | `maxTokens` | `number` | Estimated prompt budget before older history is summarized. | | `summaryModel` | `string` | Optional model used for summary generation. Defaults to the main `model`. | | `preserveRecentMessages` | `number` | Number of most recent chat messages to keep verbatim. Defaults to `12`. | | `summaryPrompt` | `string` | Optional prompt template for the summarizer. Supports `{summary}` and `{messages}` placeholders. | When the estimated prompt size exceeds `maxTokens`, the SDK summarizes older history, stores the result in the user's workspace, and keeps only the most recent messages verbatim. That summary is then injected into future system prompts. This is currently based on estimated prompt size, not exact tokenizer counts. The API is stable, but the threshold is approximate until the SDK grows native tokenizer support. ### features Toggle built-in tool categories. All default to `true`: ```typescript features: { memory: true, // memory_set, memory_get, memory_delete, memory_list artifacts: true, // artifact_create scheduling: true, // schedule_task, list_scheduled_tasks, get_scheduled_task, update_scheduled_task, delete_scheduled_task mcp: true, // mcp_call_tool (when MCP servers are installed) } ``` ### streaming Enable first-class partial generation inside the SDK workflow: ```typescript streaming: { enabled: true, throttleMs: 400, minCharsDelta: 24, maxPartialLength: 4096, } ``` When enabled, the SDK requests `stream: true` from the AI gateway, emits throttled partial updates to `hooks.onRunPartial` during generation, and still persists only the final assistant message into workspace history. Because the durable workflow does not carry a live runtime messenger instance today, streamed partials are currently consumed through hooks. The `Messenger.sendPartialMessage` method is available for transports or adapters that can bridge partial delivery themselves. Example: ```typescript import { createAgent } from "@meetploy/agent-sdk"; export default createAgent({ systemPrompt: "You are a helpful assistant.", handle: myHandler, streaming: { enabled: true, throttleMs: 250, minCharsDelta: 12, }, hooks: { async onRunPartial(ctx, partial) { await ctx.state.setJSON(`draft:${ctx.chatId}`, { key: partial.key, text: partial.text, step: partial.step, isFinal: partial.isFinal, }); }, async onRunComplete(ctx, response) { await ctx.state.delete(`draft:${ctx.chatId}`); await ctx.state.set(`last_response:${ctx.chatId}`, response); }, }, }); ``` In that flow, `onRunPartial` receives the current accumulated assistant text as it grows. `partial.key` stays stable for the in-progress assistant turn, so your UI or adapter can replace the same draft message instead of appending a new one on every update. ### tools Array of custom tool definitions. Use `defineTool()` for type-safe args: ```typescript import { defineTool } from "@meetploy/agent-sdk"; const myTool = defineTool({ name: "lookup_user", description: "Look up a user by ID", parameters: { type: "object", properties: { userId: { type: "string" } }, required: ["userId"], }, async execute(args, ctx) { // args.userId is typed as string const data = await ctx.state.get(`user:${args.userId}`); return data ?? "User not found"; }, }); ``` See [Tools](https://docs.meetploy.com/agent-sdk/tools) for full documentation. ### hooks Lifecycle hooks that fire during agent execution. Every hook receives a `HookContext` with access to `env`, `state`, and an `execute()` function for calling tools: ```typescript hooks: { async onRunComplete(ctx, response) { await ctx.state.set(`last_response:${ctx.userId}`, response); }, async onRunError(ctx, error) { console.error(`Agent error for ${ctx.userId}: ${error.message}`); }, } ``` See [Hooks](https://docs.meetploy.com/agent-sdk/hooks) for the full list. ### maxSteps Maximum iterations of the AI-call-then-tool-execution loop per run. Prevents runaway tool loops. Defaults to `30`. ```typescript maxSteps: 50; ``` ## Messenger Interface The `Messenger` you pass to `handleMessage()` defines how the agent sends responses back to the user. Implement it for your platform: ```typescript import type { Messenger } from "@meetploy/agent-sdk"; class TelegramMessenger implements Messenger { sendMessage(chatId: string, text: string) { return fetch(`https://api.telegram.org/bot${token}/sendMessage`, { method: "POST", body: JSON.stringify({ chat_id: chatId, text }), }).then(() => undefined); } sendDocument(chatId: string, filename: string, content: string | Uint8Array) { // Upload file to Telegram } sendTypingIndicator(chatId: string) { return fetch(`https://api.telegram.org/bot${token}/sendChatAction`, { method: "POST", body: JSON.stringify({ chat_id: chatId, action: "typing" }), }).then(() => undefined); } // Optional for transports that can bridge partial delivery outside // the durable workflow. The SDK's built-in streaming flow currently // emits partials through hooks.onRunPartial. sendPartialMessage?(chatId: string, partial: PartialMessage) { return updateDraft(chatId, partial.key, partial.text); } } ``` ## Return Value `createAgent()` returns a `Ploy` object with: * `fetch` -- Handles HTTP requests (health check + delegates to your `handle` function) * `timer` -- Handles scheduled task triggers * `workflows.ploy_agent_run` -- The durable agent tick loop The SDK owns the `timer` and `workflows` handlers. You do not need to define these yourself -- only implement the `handle` function. ## Next Steps * [Tools](https://docs.meetploy.com/agent-sdk/tools) -- Define custom tools with `defineTool()` * [Hooks](https://docs.meetploy.com/agent-sdk/hooks) -- React to agent lifecycle events * [State & Workspaces](https://docs.meetploy.com/agent-sdk/state) -- Understand per-user state # Hooks URL: https://docs.meetploy.com/agent-sdk/hooks # Hooks Lifecycle hooks let you run custom logic when specific events occur during an agent run. Every hook receives a `HookContext` as its first argument, giving you access to the full environment, state, and the ability to call tools. ## HookContext All hooks receive this context: ```typescript interface HookContext { userId: string; // The user who triggered the event chatId: string; // The active chat ID env: PloyEnv; // Full Ploy environment bindings state: StateManager; // Key-value state (get/set/delete/getJSON/setJSON) execute: (toolName: string, args: Record) => Promise; } ``` The `execute` function lets you call any registered tool (built-in or custom) from within a hook: ```typescript hooks: { async onMemorySet(ctx, key, secret) { // Call the artifact_create tool from within a hook await ctx.execute("artifact_create", { filename: "memory-log.txt", content: `Memory "${key}" was set (secret: ${secret})`, }); }, } ``` ## Available Hooks ### onRunPartial Fires during streamed generation when `streaming.enabled` is `true`. ```typescript onRunPartial?: (ctx: HookContext, partial: RunPartial) => Promise ``` `RunPartial` contains: ```typescript interface RunPartial { key: string; text: string; delta: string; step: number; isFinal: boolean; } ``` * `key` is stable for the current assistant turn, so you can update one draft message in place * `text` is the full accumulated partial text emitted so far * `delta` is the newest chunk for this emission * `step` is the current AI loop iteration * `isFinal` is `true` only when the SDK forces a last flush of buffered text before normal completion handling continues Example: ```typescript hooks: { async onRunPartial(ctx, partial) { await ctx.state.setJSON(`stream:${ctx.chatId}`, { key: partial.key, text: partial.text, lastDelta: partial.delta, step: partial.step, done: partial.isFinal, }); }, async onRunComplete(ctx) { await ctx.state.delete(`stream:${ctx.chatId}`); }, } ``` If you bridge partials into your own transport, treat `text` as the source of truth and use `key` as the message identity. `delta` is useful for logging or append-only transports, but `text` is the safer field for rendering. Use this hook as the primary way to observe streamed output today. The durable workflow does not hold onto the runtime `Messenger` instance passed to `handleMessage()`. `onRunPartial` is best-effort. If the hook throws, the SDK logs a warning and continues the run instead of failing the assistant response. ### onMemorySet Fires when the agent stores a memory entry. ```typescript onMemorySet?: (ctx: HookContext, key: string, secret: boolean) => Promise ``` ```typescript hooks: { async onMemorySet(ctx, key, secret) { if (secret) { await ctx.state.set(`audit:${ctx.userId}:${Date.now()}`, `secret stored: ${key}`); } }, } ``` ### onMemoryDelete Fires when the agent deletes a memory entry. ```typescript onMemoryDelete?: (ctx: HookContext, key: string) => Promise ``` ### onArtifactCreated Fires when the agent creates a file artifact. ```typescript onArtifactCreated?: (ctx: HookContext, filename: string, content: string) => Promise ``` ```typescript hooks: { async onArtifactCreated(ctx, filename, content) { // Store metadata about created artifacts const artifacts = await ctx.state.getJSON(`artifacts:${ctx.userId}`) ?? []; artifacts.push(filename); await ctx.state.setJSON(`artifacts:${ctx.userId}`, artifacts); }, } ``` ### onTaskScheduled Fires when the agent schedules a future task. ```typescript onTaskScheduled?: (ctx: HookContext, task: ScheduledTask) => Promise ``` The `ScheduledTask` contains: ```typescript interface ScheduledTask { id: string; description: string; scheduledTime: number; recurring: boolean; intervalMs?: number; } ``` ### onRunComplete Fires when the agent finishes a run with a response (no more tool calls). ```typescript onRunComplete?: (ctx: HookContext, response: string) => Promise ``` ```typescript hooks: { async onRunComplete(ctx, response) { // Log the response for analytics await ctx.state.set( `log:${ctx.userId}:${Date.now()}`, response.slice(0, 500), ); }, } ``` ### onRunError Fires when the agent run fails with an error. ```typescript onRunError?: (ctx: HookContext, error: Error) => Promise ``` ```typescript hooks: { async onRunError(ctx, error) { // Notify an external service await fetch("https://alerts.example.com/webhook", { method: "POST", body: JSON.stringify({ userId: ctx.userId, error: error.message, }), }); }, } ``` Hooks run inside the durable workflow, so they benefit from automatic retries on failure. Keep hooks lightweight to avoid slowing down the agent loop. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- Pass hooks via the `hooks` config option * [Tools](https://docs.meetploy.com/agent-sdk/tools) -- Tools that trigger hooks (memory, artifacts, scheduling) * [Workflows](https://docs.meetploy.com/agent-sdk/workflows) -- Understand the durable tick loop that runs hooks # Agent SDK URL: https://docs.meetploy.com/agent-sdk # Agent SDK The Ploy Agent SDK (`@meetploy/agent-sdk`) wraps Ploy primitives -- state, workflows, timers, and file storage -- into a single `createAgent()` call. You get a fully functional AI agent with a durable tick loop, built-in tools, per-user workspaces, and lifecycle hooks out of the box. ## What You Get * **Durable workflow tick loop** -- AI calls and tool execution survive failures and restarts * **Built-in tools** -- Memory, artifacts, scheduling, and MCP server integration * **Per-user workspaces** -- Conversation history, memory, and settings isolated per user * **Type-safe custom tools** -- `defineTool()` infers argument types from JSON Schema * **Lifecycle hooks** -- React to memory changes, artifacts, scheduled tasks, and run completion with full env access * **Message queuing** -- Messages received during an active run are queued and processed in order * **Context compression** -- Optional summary-based compression for long-running conversations * **Commands** -- Built-in `/reset`, `/debug`, `/tools`, and MCP management ## Quick Start Install the SDK: ```bash pnpm add @meetploy/agent-sdk ``` Configure your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker agentSDK: true ``` The `agentSDK: true` flag automatically sets up all required bindings (`ai`, `state`, `fs`, `workflow`, `timer`) with the correct names and defaults. No manual binding configuration needed. Create your agent: ```typescript title="src/index.ts" import { createAgent, defineTool } from "@meetploy/agent-sdk"; const weatherTool = defineTool({ name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", properties: { city: { type: "string", description: "City name" }, }, required: ["city"], }, async execute(args, ctx) { // args.city is typed as string const response = await fetch(`https://wttr.in/${args.city}?format=3`); return await response.text(); }, }); export default createAgent({ systemPrompt: "You are a helpful weather assistant.", tools: [weatherTool], handle(req, env, agent) { // Your custom HTTP handler -- parse the request, // call agent.handleMessage(), return a response return new Response("OK"); }, }); ``` That's it. The SDK handles the durable workflow loop, AI calls, tool execution, workspace persistence, and message queuing automatically. ## How It Works When a user sends a message: 1. `handleMessage()` loads or creates the user's workspace 2. If a run is already active, the message is queued 3. Otherwise, a durable workflow is triggered 4. The workflow loops: call the AI, execute any tool calls, repeat 5. When the AI responds without tool calls, the run completes 6. Any queued messages trigger a new run The SDK owns the `fetch`, `timer`, and `workflows` handlers on the returned Ploy export. Your custom logic goes in the `handle` function, which receives the request, env, and an `AgentContext` with `handleMessage`, `commands`, and `state`. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- Configuration options and the handle function * [Tools](https://docs.meetploy.com/agent-sdk/tools) -- Built-in tools and defining custom tools with `defineTool()` * [Hooks](https://docs.meetploy.com/agent-sdk/hooks) -- Lifecycle hooks for reacting to agent events * [State & Workspaces](https://docs.meetploy.com/agent-sdk/state) -- Per-user workspace management and the StateManager * [Commands](https://docs.meetploy.com/agent-sdk/commands) -- Built-in commands for users * [Workflows](https://docs.meetploy.com/agent-sdk/workflows) -- How the durable tick loop works # State & Workspaces URL: https://docs.meetploy.com/agent-sdk/state # State & Workspaces The Agent SDK manages per-user state automatically through **workspaces**. Each user gets an isolated workspace containing their conversation history, optional conversation summary, memory entries, MCP server configs, and run state. The `StateManager` provides a typed wrapper over the raw Ploy `StateBinding`. ## Workspaces A workspace is created automatically when a user sends their first message. It's stored in the state binding under the key `agent:workspace:{userId}`. ```typescript interface Workspace { userId: string; chatId: string; platform: string; messages: ChatMessage[]; // Conversation history conversationSummary: string | null; // Running summary used for compressed context memory: Record; // User memories (set via memory tools) mcpServers: McpServer[]; // Installed MCP servers debugMode: boolean; // Debug mode toggle activeRunId: string | null; // Active workflow run ID (null if idle) queue: QueuedMessage[]; // Messages queued during active runs } ``` ### Automatic Behaviors The SDK handles these workspace operations automatically: * **Creation** -- A default workspace is created on the user's first message * **Message trimming** -- Without context compression, conversation history is capped at 50 messages * **Context compression** -- With `contextCompression` enabled, older history is summarized into `conversationSummary` * **Memory injection** -- Memory entries are appended to the system prompt (secrets are hidden) * **Summary injection** -- `conversationSummary` is appended to the system prompt when present * **Message queuing** -- Messages received during an active run are queued and processed in order * **Run tracking** -- `activeRunId` prevents concurrent runs for the same user ## StateManager The `StateManager` wraps the raw `StateBinding` with JSON-aware helpers. It's available on `AgentContext.state`, `ToolContext.state`, and `HookContext.state`. ### API ```typescript interface StateManager { get(key: string): Promise; set(key: string, value: string): Promise; delete(key: string): Promise; update(key: string, update: StateUpdateDoc): Promise; getJSON(key: string): Promise; setJSON(key: string, value: unknown): Promise; } ``` The `update` method applies a MongoDB-style update document to JSON values atomically using SQLite JSON functions. Supported operators: `$set`, `$unset`, `$inc`, `$push`, `$pop`. See [State > update()](https://docs.meetploy.com/features/state#updatekey-update---atomic-json-updates) for details. ### Usage in handle() ```typescript handle: async (req, env, agent) => { // Read state directly const visits = (await agent.state.getJSON("visits")) ?? 0; await agent.state.setJSON("visits", visits + 1); return Response.json({ visits: visits + 1 }); }; ``` ### Usage in Tools ```typescript const myTool = defineTool({ name: "save_note", description: "Save a note", parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"], }, async execute(args, ctx) { await ctx.state.set(`note:${ctx.userId}`, args.text); return "Note saved."; }, }); ``` ### Usage in Hooks ```typescript hooks: { async onRunComplete(ctx, response) { const count = await ctx.state.getJSON(`runs:${ctx.userId}`) ?? 0; await ctx.state.setJSON(`runs:${ctx.userId}`, count + 1); }, } ``` ### Atomic Workspace Updates The SDK provides `updateWorkspace()` for partial updates to workspace state without loading and saving the entire object: ```typescript import { updateWorkspace } from "@meetploy/agent-sdk"; // Set a single field await updateWorkspace(state, userId, { $set: { activeRunId: null }, }); // Append to the message queue await updateWorkspace(state, userId, { $push: { queue: { text: "hello", timestamp: Date.now() } }, }); // Multiple atomic operations await updateWorkspace(state, userId, { $set: { activeRunId: "run_123", chatId: newChatId, platform: "slack" }, }); ``` This is more efficient than `loadWorkspace` + modify + `saveWorkspace` and avoids race conditions when concurrent requests touch the same workspace. ## Memory Entries Memory entries are key-value pairs stored in the workspace. The agent can set, get, delete, and list them using the built-in memory tools. Memories are automatically injected into the system prompt so the agent remembers user preferences across conversations. ```typescript interface MemoryEntry { value: string; secret: boolean; // Hidden in debug output and system prompt createdAt: number; } ``` Secret memories (e.g., API keys) are injected with the value `[SECRET]` in the system prompt and debug views, but the full value is available to tools. State values are strings. Use `getJSON`/`setJSON` for structured data. The SDK uses `JSON.stringify`/`JSON.parse` internally. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- The `state` property on `AgentContext` * [Tools](https://docs.meetploy.com/agent-sdk/tools) -- Access state via `ctx.state` in tool execute functions * [Commands](https://docs.meetploy.com/agent-sdk/commands) -- The `/reset` command deletes the user's workspace # Tools URL: https://docs.meetploy.com/agent-sdk/tools # Tools The Agent SDK provides built-in tools for common agent capabilities and a `defineTool()` helper for creating custom tools with full TypeScript type inference. ## Built-in Tools All built-in tools are enabled by default and can be toggled via `features` in `AgentConfig`. ### Memory Tools Store and recall user-specific information across conversations. | Tool | Description | | --------------- | ---------------------------------------------------- | | `memory_set` | Store a key-value pair (optionally marked as secret) | | `memory_get` | Retrieve a stored value by key | | `memory_delete` | Delete a stored value | | `memory_list` | List all stored memory keys | Memory entries are persisted in the user's workspace and automatically injected into the system prompt. Secret entries are hidden from debug output. ```typescript features: { memory: true; } // default ``` ### Artifact Tool Create files and send them to the user. | Tool | Description | | ----------------- | ---------------------------------------------------------------------- | | `artifact_create` | Create a file, store it in file storage, and send it via the messenger | Files are stored under `{userId}/{filename}` in the `FILES` binding. ```typescript features: { artifacts: true; } // default ``` ### Scheduling Tools Schedule and manage tasks for future execution. | Tool | Description | | ----------------------- | --------------------------------------------------- | | `schedule_task` | Schedule a one-time or recurring task | | `list_scheduled_tasks` | List all tasks the agent has scheduled | | `get_scheduled_task` | Get the current status of a specific scheduled task | | `update_scheduled_task` | Update a task's description, time, or recurrence | | `delete_scheduled_task` | Cancel a scheduled task so it will no longer fire | When a scheduled time arrives, the SDK triggers a new workflow run with the task description injected as a user message. `list_scheduled_tasks` returns tasks tracked in the user's workspace. `get_scheduled_task` queries the scheduler binding directly, so it reflects real-time status (returns not-found for tasks that have already fired). ```typescript features: { scheduling: true; } // default ``` ### MCP Tool Call tools provided by installed MCP (Model Context Protocol) servers. | Tool | Description | | --------------- | -------------------------------------- | | `mcp_call_tool` | Call a tool on an installed MCP server | Only available when at least one MCP server is installed in the user's workspace. See [Commands](https://docs.meetploy.com/agent-sdk/commands) for how users install MCP servers. ```typescript features: { mcp: true; } // default ``` ## Custom Tools with `defineTool()` `defineTool()` creates a type-safe tool definition where the `execute` function's `args` parameter is automatically typed from the JSON Schema `parameters` object. ### Basic Example ```typescript import { defineTool } from "@meetploy/agent-sdk"; const searchTool = defineTool({ name: "search", description: "Search for information", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" }, limit: { type: "number", description: "Max results" }, }, required: ["query"], }, async execute(args, ctx) { // args.query: string (required) // args.limit?: number (optional -- not in required array) const results = await doSearch(args.query, args.limit ?? 10); return JSON.stringify(results); }, }); ``` ### Type Inference `defineTool()` uses TypeScript's `const` generic inference to map JSON Schema types to TypeScript types: | JSON Schema `type` | TypeScript type | | ------------------ | ------------------------- | | `"string"` | `string` | | `"number"` | `number` | | `"integer"` | `number` | | `"boolean"` | `boolean` | | `"object"` | `Record` | | `"array"` | `unknown[]` | Properties listed in `required` are required; all others are optional (`?`). ### ToolContext The `ctx` parameter in `execute` provides access to user context and platform bindings: ```typescript interface ToolContext { userId: string; // Current user ID chatId: string; // Current chat ID env: PloyEnv; // Full Ploy environment bindings state: StateManager; // Key-value state (get/set/delete/getJSON/setJSON) messenger: Messenger; // Platform messenger } ``` ### Using ToolDefinition Directly If you prefer to type args manually instead of using `defineTool()`, use `ToolDefinition`: ```typescript import type { ToolDefinition } from "@meetploy/agent-sdk"; const myTool: ToolDefinition<{ city: string; units?: string }> = { name: "get_weather", description: "Get weather for a city", parameters: { type: "object", properties: { city: { type: "string" }, units: { type: "string" }, }, required: ["city"], }, async execute(args, ctx) { // args is { city: string; units?: string } return `Weather in ${args.city}`; }, }; ``` Custom tools must return a `Promise`. The returned string is sent back to the AI as the tool result for the next iteration of the tick loop. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- Pass tools via the `tools` config option * [Hooks](https://docs.meetploy.com/agent-sdk/hooks) -- React when tools create artifacts or schedule tasks * [State & Workspaces](https://docs.meetploy.com/agent-sdk/state) -- Access state from within tool execute functions # Workflows URL: https://docs.meetploy.com/agent-sdk/workflows # Workflows The Agent SDK uses Ploy's durable workflow system to run the agent loop. Each user message triggers a workflow execution that loops through AI calls and tool execution until the agent produces a final response. Every step is persisted, so the loop survives failures and restarts. ## The Tick Loop When `handleMessage()` triggers a workflow, the `ploy_agent_run` workflow executes these steps: ### 1. Load Workspace Loads the user's workspace from state (or creates a default one). Appends the user's message to the conversation history and trims to the last 50 messages. ### 2. Build System Prompt Resolves the system prompt (static string or async function) and appends the user's memory entries. ### 3. AI Call + Tool Execution Loop The core loop runs up to `maxSteps` iterations (default: 30): 1. Build the message array (system prompt + conversation history) 2. Call `env.AI.run(model, inputs, options?)` 3. If `streaming.enabled` is on, the SDK parses OpenAI-compatible SSE chunks and emits throttled `onRunPartial` hook updates with the current accumulated assistant text 4. If the AI responds **without tool calls** -- the run is complete, the response is stored 5. If the AI responds **with tool calls** -- execute each tool, append results to the conversation, and loop Each iteration is a durable `step.run()`, so completed steps are skipped on retry. Only the final assistant message is written into workspace history. Streamed partials are transient and intended for hooks or transport adapters. ### 4. Check Queue After the run completes, the workflow checks for queued messages. If any exist, the first is dequeued and a new workflow is triggered to process it. ## Durability Every step in the workflow is persisted by Ploy's workflow engine: * **AI calls** -- `ai_call_1`, `ai_call_2`, etc. * **Tool executions** -- `tools_1`, `tools_2`, etc. * **State saves** -- `save_step_1`, `save_final_2`, etc. If a step fails, it's automatically retried. Completed steps are skipped on re-execution. ## Message Queuing When a user sends a message while a run is active, the message is queued in the workspace: ```typescript // This happens automatically inside handleMessage() if (workspace.activeRunId) { workspace.queue.push({ text, timestamp: Date.now() }); await saveWorkspace(state, workspace); return; // Don't trigger a new workflow } ``` After the current run completes, the workflow dequeues and processes the next message. This ensures messages are processed in order without concurrent runs. ## Configuration The workflow is named `ploy_agent_run` and is automatically configured when you set `agentSDK: true` in `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker agentSDK: true ``` This expands to `workflow: { PLOY_AGENT_WORKFLOW: ploy_agent_run }` along with all other required bindings. The SDK registers the workflow handler automatically. You do not need to implement it. ### maxSteps Control the maximum iterations of the AI-tool loop: ```typescript createAgent({ maxSteps: 50, // default: 30 // ... }); ``` This prevents runaway tool loops from consuming resources. ## Timer Integration Scheduled tasks use Ploy's timer binding. When a timer fires, the SDK's `timer` handler: 1. Reads the task payload (userId, chatId, platform, description) 2. Injects `[Scheduled task]: {description}` as a user message 3. Triggers a new `ploy_agent_run` workflow This means scheduled tasks are processed through the same durable loop as regular messages. The timer binding (`PLOY_AGENT_SCHEDULER`) is also automatically configured by `agentSDK: true`. The workflow and timer handlers are owned by the SDK. With `agentSDK: true`, all bindings are configured automatically -- you only need to implement the `handle` function for incoming requests. ## Next Steps * [createAgent()](https://docs.meetploy.com/agent-sdk/create-agent) -- Configure `maxSteps` and other options * [Hooks](https://docs.meetploy.com/agent-sdk/hooks) -- `onRunComplete` and `onRunError` fire at the end of the loop * [State & Workspaces](https://docs.meetploy.com/agent-sdk/state) -- How workspace state persists across workflow steps * [Ploy Workflows](https://docs.meetploy.com/features/workflows) -- The underlying Ploy workflow system # ploy dev URL: https://docs.meetploy.com/cli/dev # ploy dev Start the local development server with hot reloading. `ploy dev` automatically detects whether your project is a **worker**, a **Next.js** app, or a **Cloudflare Vite** app and starts the appropriate development environment. For Cloudflare Vite projects, `ploy dev` starts the Vite dev server alongside the Ploy dashboard. See the [Vite](https://docs.meetploy.com/vite) guide for how the integration works and how it's configured. ```bash ploy dev [options] ``` ## Options | Flag | Description | Default | | --------------------------- | --------------------- | ------------- | | `-p, --port ` | Server port | `3000` | | `-h, --host ` | Server host | `localhost` | | `-c, --config ` | Path to `ploy.yaml` | auto-detected | | `--no-watch` | Disable file watching | watch enabled | | `-v, --verbose` | Verbose output | `false` | | `--dashboard-port ` | Dev dashboard port | `port + 1000` | ## Worker Projects For worker projects (`kind: worker` in `ploy.yaml`), `ploy dev` starts the full local emulator with all Ploy services available: * SQLite database (D1-compatible) * Message queues * Workflows * Cache * State * File storage * Auth * Scheduled jobs ```bash ploy dev # Worker running at http://localhost:3000 # Dashboard at http://localhost:4000 ``` The dev dashboard lets you inspect queued messages, workflow runs, database tables, and other runtime state while developing locally. All services run entirely locally — no cloud connection required during development. ### Custom Port ```bash ploy dev -p 8080 # Worker at http://localhost:8080 # Dashboard at http://localhost:9080 ``` ### Custom Dashboard Port ```bash ploy dev --dashboard-port 5000 # Worker at http://localhost:3000 # Dashboard at http://localhost:5000 ``` ### Watch Mode File watching is enabled by default. Changes to your worker source files trigger a hot reload. Disable it with `--no-watch`: ```bash ploy dev --no-watch ``` ## Next.js Projects For Next.js projects, `ploy dev` starts `next dev` alongside a Ploy mock server that provides all runtime bindings (database, queues, auth, etc.) to your Next.js app. **Auto-detection**: a project is considered Next.js if: 1. `ploy.yaml` has `kind: nextjs`, **or** 2. A `next.config.ts`, `next.config.js`, `next.config.mts`, or `next.config.mjs` file exists ```bash ploy dev # Next.js at http://localhost:3000 # Ploy dashboard http://localhost:4000 ``` The `PLOY_MOCK_SERVER_URL` environment variable is automatically set so that `@meetploy/nextjs` connects to the local mock server instead of the production API. ### Minimal Next.js ploy.yaml ```yaml title="ploy.yaml" kind: nextjs db: DB: default auth: binding: PLOY_AUTH ``` ## Verbose Output Use `-v` / `--verbose` to see detailed logs from the emulator and all runtime services: ```bash ploy dev -v ``` ## Project Setup Make sure your `package.json` includes a `types` script that calls `ploy types` so that type definitions stay in sync: ```json title="package.json" { "scripts": { "dev": "ploy dev", "types": "ploy types", "build": "ploy build" } } ``` # CLI Overview URL: https://docs.meetploy.com/cli # Ploy CLI The `@meetploy/cli` package provides the `ploy` command for local development, TypeScript type generation, and building workers. ## Installation ```bash # Global install (recommended for local development) npm install -g @meetploy/cli # Or as a dev dependency in your project npm install -D @meetploy/cli ``` ## Commands | Command | Description | | -------------------------- | ------------------------------------------------- | | [`ploy dev`](https://docs.meetploy.com/cli/dev) | Start local development server with hot reloading | | [`ploy types`](https://docs.meetploy.com/cli/types) | Generate TypeScript types from `ploy.yaml` | | [`ploy vite`](https://docs.meetploy.com/cli/vite) | Run Cloudflare Vite with `ploy.yaml` config | | `ploy build` | Build your worker project | | `ploy auth login` | Authenticate with the Ploy API | | `ploy auth logout` | Clear stored credentials | | `ploy api` | Interact with the Ploy platform API | ## Quick Start Create a `ploy.yaml` at the root of your project, then run: ```bash # Generate TypeScript types for your bindings ploy types # Start the local development server ploy dev ``` ## ploy.yaml The `ploy.yaml` file configures the Ploy runtime bindings available to your worker or Next.js app. The CLI reads this file for both `ploy dev` and `ploy types`. ```yaml title="ploy.yaml" kind: worker # worker | nextjs | static build: pnpm build out: dist # Runtime bindings — format is BINDING_NAME: resource_name db: DB: default queue: QUEUE: tasks workflow: FLOW: my_flow state: STATE: default ai: true auth: binding: PLOY_AUTH ``` After editing `ploy.yaml`, run `ploy types` to regenerate your TypeScript types. See the [Configuration](https://docs.meetploy.com/configuration) page for the full `ploy.yaml` reference. ## Authentication To use the `ploy api` commands, authenticate first: ```bash ploy auth login ``` This opens a browser window for OAuth login. Credentials are stored in `~/.ploy/config.json`. You can also set `PLOY_API_TOKEN` as an environment variable to skip interactive login. # ploy types URL: https://docs.meetploy.com/cli/types # ploy types Generate TypeScript type definitions from your `ploy.yaml` bindings. Creates an `env.d.ts` file and updates `tsconfig.json` so that your editor and compiler know about all the runtime bindings available to your worker or Next.js app. ```bash ploy types [options] ``` ## Options | Flag | Description | Default | | --------------------- | ---------------- | ---------- | | `-o, --output ` | Output file path | `env.d.ts` | ## Usage Run `ploy types` at the root of your project after any change to `ploy.yaml`: ```bash ploy types # Generated env.d.ts ``` The generated file is automatically referenced in `tsconfig.json`. Commit it to your repository. ### Custom Output Path ```bash ploy types -o src/env.d.ts ``` ## Generated Output Given a `ploy.yaml` like: ```yaml title="ploy.yaml" kind: worker db: DB: default queue: TASKS: tasks workflow: FLOW: my_flow state: STATE: default auth: binding: PLOY_AUTH ai: true ``` `ploy types` generates: ```typescript title="env.d.ts" declare module "@meetploy/nextjs" { interface PloyEnv { vars: Record; DB: Database; TASKS: QueueBinding; FLOW: WorkflowBinding; STATE: StateBinding; PLOY_AUTH: PloyAuth; AI: Ai; PLOY_AI_URL: string; PLOY_AI_TOKEN: string; } } declare global { interface PloyEnv { vars: Record; DB: Database; TASKS: QueueBinding; FLOW: WorkflowBinding; STATE: StateBinding; PLOY_AUTH: PloyAuth; AI: Ai; PLOY_AI_URL: string; PLOY_AI_TOKEN: string; } } export {}; ``` `vars` is always present and typed as `Record`. Environment variables are not declared in `ploy.yaml`, so their keys are not known when the types are generated — see [Environment Variables](https://docs.meetploy.com/features/env-vars). With `ai: true`, the generated types expose `env.AI.run()` first, and also include `PLOY_AI_URL` / `PLOY_AI_TOKEN` for direct gateway calls when needed. ## Supported Binding Types | ploy.yaml key | Type in PloyEnv | | ---------------- | ------------------------------------------------------------ | | `db` | `Database` per binding name | | `queue` | `QueueBinding` per binding name | | `workflow` | `WorkflowBinding` per binding name | | `state` | `StateBinding` per binding name (`KVNamespace` compatible) | | `fs` | `FileStorageBinding` per binding name | | `timer` | `TimerBinding` per binding name | | `auth` | `PloyAuth` as `PLOY_AUTH` | | `ai: true` | `AI: Ai`, `PLOY_AI_URL: string`, and `PLOY_AI_TOKEN: string` | | `agentSDK: true` | All Agent SDK bindings (ai, state, fs, workflow, timer) | `StateBinding` keeps Ploy's existing binding names while supporting the Cloudflare KV methods `get`, `put`, `delete`, `getWithMetadata`, and `list`. When `@meetploy/agent-sdk` is installed in your project and `agentSDK: true` is not set in `ploy.yaml`, running `ploy types` will automatically add it for you. ## Using the Types After running `ploy types`, the `PloyEnv` global interface is available everywhere in your project. With `@meetploy/start`: ```typescript title="src/index.ts" import { ploy } from "@meetploy/start"; const worker = ploy() .get("/", {}, async (ctx) => { // ctx.env is fully typed const users = await ctx.env.DB.prepare("SELECT * FROM users").all(); return { users: users.results }; }) .build(); export default worker; ``` With a plain worker: ```typescript title="src/index.ts" export default { async fetch(request, env) { const result = await env.DB.prepare("SELECT * FROM users").all(); return Response.json(result.results); }, }; ``` With Next.js via `@meetploy/nextjs`: ```typescript title="app/api/route.ts" import { getPloyEnv } from "@meetploy/nextjs"; export async function GET() { const env = getPloyEnv(); const result = await env.DB.prepare("SELECT * FROM users").all(); return Response.json(result.results); } ``` Run `ploy types` any time you add or remove bindings in `ploy.yaml`. Add it to your `postinstall` or `prepare` script to keep types in sync automatically. ## package.json Integration ```json title="package.json" { "scripts": { "types": "ploy types", "dev": "ploy dev", "build": "ploy build", "postinstall": "ploy types" } } ``` # ploy vite URL: https://docs.meetploy.com/cli/vite # ploy vite Use `ploy vite` for Ploy Vite projects, including React apps that ship a worker from `worker/index.ts` and TanStack Start apps. For an overview of how the Vite integration works and how it's configured, see the [Vite](https://docs.meetploy.com/vite) guide. This page is the command reference. Ploy reads `ploy.yaml` and drives the Vite toolchain from it, so all of your bindings, assets, and compatibility settings live in one place. ## Commands ```bash ploy vite build [options] ploy vite dev [options] ``` ## Options | Flag | Description | Default | | ---------------------- | ----------------------------- | ------------- | | `--ploy-config ` | Path to `ploy.yaml` | auto-detected | | `...args` | Additional arguments for Vite | none | Any extra arguments are forwarded to the underlying Vite command. ## When to use it Use `ploy vite` when your project has both: * a Vite config * `@meetploy/vite` installed For plain workers, use `ploy dev` / `ploy build`. For Next.js, use `ploy dev` with `@meetploy/nextjs`. ## React + Worker Example This setup matches [examples/vite-full](https://github.com/meetploy/ploy/tree/main/examples/vite-full). ```json title="package.json" { "scripts": { "build": "tsc --noEmit && ploy vite build", "dev": "ploy vite dev", "types": "ploy types -o env.d.ts" } } ``` ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist compatibility_date: "2026-03-23" assets: binding: ASSETS not_found_handling: single-page-application run_worker_first: - /api/* - "!/api/docs/*" db: DB: default ``` ```typescript title="vite.config.ts" import { ploy } from "@meetploy/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), ...ploy()], }); ``` For non-TanStack projects, Ploy looks for a worker entry at `worker/index.ts` or the corresponding JavaScript/MJS variants. ## TanStack Start Example This setup matches [examples/tanstack-start-prerender](https://github.com/meetploy/ploy/tree/main/examples/tanstack-start-prerender). ```json title="package.json" { "scripts": { "build": "ploy vite build && tsc --noEmit", "types": "ploy types -o env.d.ts" } } ``` ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist compatibility_date: "2026-03-17" compatibility_flags: - nodejs_compat ``` ```typescript title="vite.config.ts" import { ploy } from "@meetploy/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ ...ploy({ viteEnvironment: { name: "ssr" } }), tanstackStart({ prerender: { enabled: true, autoSubfolderIndex: true, crawlLinks: false, failOnError: true, }, }), viteReact(), ], }); ``` When `@tanstack/react-start` is installed, Ploy automatically maps the worker entry to `@tanstack/react-start/server-entry`. ## Assets Use the top-level `assets` key in `ploy.yaml` to control static asset behavior for dynamic Vite deployments. ```yaml title="ploy.yaml" assets: binding: ASSETS not_found_handling: single-page-application run_worker_first: - /api/* - "!/api/docs/*" ``` * `binding` exposes the asset fetcher to your worker as `env.ASSETS` * `not_found_handling: single-page-application` enables SPA fallback behavior * `run_worker_first` lets API routes or other patterns bypass the asset layer ## Local Development ```bash pnpm ploy vite dev ``` This starts Vite dev mode with the config synthesized from `ploy.yaml`. If your app also uses Ploy bindings like `db`, generate types with `ploy types` first so `env.d.ts` stays current. ## Build ```bash pnpm ploy vite build ``` This runs the production Vite build using your `ploy.yaml` compatibility settings and asset config. ## Notes * Keep compatibility settings in `ploy.yaml`. * Use `assets` in `ploy.yaml` to control static file behavior. * If you need a custom Ploy config path, run `ploy vite build --ploy-config path/to/ploy.yaml`. * If you need to pass Vite's own `--config`, forward it directly: `ploy vite build -- --config vite.config.custom.ts`. # AI Integration URL: https://docs.meetploy.com/features/ai # AI Integration Ploy provides built-in AI integration for workers, allowing you to build AI-powered applications with `env.AI.run()` and with direct OpenAI-compatible SDK calls when needed. ## Enabling AI To use AI in your worker, add `ai: true` to your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic ai: true ``` This injects an `env.AI` binding plus the raw escape-hatch environment variables: * `AI` - AI binding with `env.AI.run(model, inputs, options?)` * `PLOY_AI_URL` - OpenAI-compatible base URL for direct SDK or HTTP requests * `PLOY_AI_TOKEN` - Authentication token (automatically managed per deployment) ## Local Development When you run `ploy dev`, remote AI bindings are attributed to an organization-scoped local development AI sandbox in Ploy. * If your checkout matches a deployed project, Ploy uses that project to choose the organization, then routes local AI usage through the organization's sandbox. * If your checkout does not match a deployed project and you belong to multiple organizations, set `PLOY_DEV_AI_PROJECT_ID` to any project in the target organization to choose the correct sandbox. * For full manual control, set both `PLOY_DEV_AI_URL` and `PLOY_DEV_AI_TOKEN`. Example: ```bash PLOY_DEV_AI_PROJECT_ID=proj_123 pnpm exec ploy dev ``` ## Basic AI Example Here's a simple worker that calls an AI model: ```typescript title="src/index.ts" interface Env { AI: Ai; PLOY_AI_URL: string; PLOY_AI_TOKEN: string; } export default { async fetch(request, env) { const url = new URL(request.url); // Health check endpoint if (url.pathname === "/health") { return new Response("ok"); } // AI endpoint if (url.pathname === "/ai") { try { // Get parameters from query string const model = url.searchParams.get("model") || "glm-4.6v-flash"; const prompt = url.searchParams.get("prompt") || "just reply 'OK'"; const data = await env.AI.run(model, { prompt }); return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } return new Response("hi!"); }, }; ``` ### Usage ```bash # Basic request curl https://your-deployment.ploy.app/ai # Custom prompt curl "https://your-deployment.ploy.app/ai?prompt=What is TypeScript?" # Different model curl "https://your-deployment.ploy.app/ai?model=gpt-4&prompt=Hello" ``` ## `env.AI.run()` Ploy exposes `env.AI.run(model, inputs, options?)`: ```typescript const response = await env.AI.run("auto", { prompt: "Hello, World", }); ``` `env.AI.run()` is the primary worker API. The raw `PLOY_AI_URL` and `PLOY_AI_TOKEN` variables remain available when you want to call the OpenAI-compatible gateway directly with your own client. ## Examples * `examples/ai-simple` uses `env.AI.run()` for the basic request path. * `examples/ai-streaming` uses `env.AI.run()` with `stream: true` and returns SSE. * `examples/ai-openai` uses the OpenAI SDK with `PLOY_AI_URL` and `PLOY_AI_TOKEN`. ## OpenAI-Compatible API Ploy's AI integration uses the OpenAI chat completions format: ### Request Format ```typescript { model: string; // Model name (e.g., "glm-4.6v-flash", "gpt-4") messages: Array<{ // Conversation messages role: "system" | "user" | "assistant"; content: string; }>; temperature?: number; // Randomness (0-2, default: 1) max_tokens?: number; // Maximum response length top_p?: number; // Nucleus sampling (0-1, default: 1) stream?: boolean; // Enable streaming responses } ``` ### Response Format ```typescript { id: string; object: "chat.completion"; created: number; model: string; choices: Array<{ index: number; message: { role: "assistant"; content: string; }; finish_reason: string; }>; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; } } ``` ## Conversation Context Build conversational applications by maintaining message history: ```typescript interface Message { role: "system" | "user" | "assistant"; content: string; } export default { async fetch(request, env) { if (request.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } try { const { messages, model = "glm-4.6v-flash" } = await request.json(); // Add system prompt const fullMessages: Message[] = [ { role: "system", content: "You are a helpful assistant that provides concise answers.", }, ...messages, ]; const data = await env.AI.run(model, { messages: fullMessages, }); return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" }, }); } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } }, }; ``` ## Direct SDK Usage If you need a library that expects an OpenAI-compatible `baseURL` and `apiKey`, use the injected raw variables: ```typescript import OpenAI from "openai"; const client = new OpenAI({ apiKey: env.PLOY_AI_TOKEN, baseURL: env.PLOY_AI_URL, }); const response = await client.chat.completions.create({ model: "auto", messages: [{ role: "user", content: "Hello" }], }); ``` ## Vercel AI SDK Next.js projects with `ai: true` get Ploy registered as the [AI SDK](https://ai-sdk.dev)'s default provider, so a bare model id resolves through Ploy instead of the Vercel AI Gateway: ```typescript title="app/api/chat/route.ts" import { convertToModelMessages, streamText } from "ai"; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: "anthropic/claude-sonnet-5", messages: await convertToModelMessages(messages), }); return result.toUIMessageStreamResponse(); } ``` No provider setup, no `AI_GATEWAY_API_KEY`, and no code change when moving an existing AI Gateway app onto Ploy — model ids keep the same `provider/model` format. Ploy wires this up during the build (and during `ploy dev`) by generating two files next to your `app` directory: * `ploy-ai-provider.js` — registers `globalThis.AI_SDK_DEFAULT_PROVIDER` * `instrumentation.js` — imports it through the Next.js instrumentation hook If you already have an `instrumentation` file, Ploy adds the import to it and leaves the rest alone. Both files are regenerated on every build, so you can commit them or add them to `.gitignore`. This needs `ai` v5 or later in your project. To opt out, set `globalThis.AI_SDK_DEFAULT_PROVIDER` yourself or pass a model instance instead of a string — Ploy never overwrites a provider that is already registered. Set `PLOY_AI_GATEWAY_URL` to point the AI SDK at a specific gateway URL. By default it is derived from `PLOY_AI_URL`. ## Streaming `env.AI.run()` also supports streaming: ```typescript const stream = await env.AI.run("auto", { prompt: "Write one short sentence.", stream: true, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8", }, }); ``` ### Usage ```bash curl -X POST https://your-deployment.ploy.app/ai \ -H "Content-Type: application/json" \ -d '{ "messages": [ {"role": "user", "content": "What is JavaScript?"}, {"role": "assistant", "content": "JavaScript is a programming language..."}, {"role": "user", "content": "How do I use async/await?"} ] }' ``` ## LangChain Integration Use [LangChain](https://js.langchain.com/) for advanced AI features like agents and tools: ### Installation ```json title="package.json" { "dependencies": { "@langchain/core": "^1.1.0", "@langchain/openai": "^1.1.3", "langchain": "^1.1.1", "zod": "^3.24.1" } } ``` ### Creating Tools Define tools that the AI can use: ```typescript title="src/index.ts" import { ChatOpenAI } from "@langchain/openai"; import { createAgent, tool } from "langchain"; import * as z from "zod"; interface Env { PLOY_AI_URL: string; PLOY_AI_TOKEN: string; } // Define a tool for getting weather information const getWeather = tool((input) => `It's always sunny in ${input.city}!`, { name: "get_weather", description: "Get the weather for a given city", schema: z.object({ city: z.string().describe("The city to get the weather for"), }), }); export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/health") { return new Response("ok"); } if (url.pathname === "/ai") { try { const prompt = url.searchParams.get("prompt") || "just reply 'OK'"; // Initialize OpenAI-compatible model const model = new ChatOpenAI({ model: "glm-4.6v-flash", apiKey: env.PLOY_AI_TOKEN, configuration: { baseURL: env.PLOY_AI_URL, }, }); // Create agent with tools const agent = createAgent({ model, tools: [getWeather], }); // Invoke agent const result = await agent.invoke({ messages: [{ role: "user", content: prompt }], }); // Extract response const lastMessage = result.messages[result.messages.length - 1]; const content = typeof lastMessage.content === "string" ? lastMessage.content : JSON.stringify(lastMessage.content); // Return in OpenAI format return new Response( JSON.stringify({ choices: [ { message: { role: "assistant", content: content, }, }, ], }), { status: 200, headers: { "Content-Type": "application/json" }, }, ); } catch (error) { return new Response( JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } return new Response("LangChain agent example!"); }, }; ``` ## Token Usage and Billing Monitor token consumption in API responses: ```typescript const data = await response.json(); // Extract token usage const usage = data.usage; console.log(`Prompt tokens: ${usage.prompt_tokens}`); console.log(`Completion tokens: ${usage.completion_tokens}`); console.log(`Total tokens: ${usage.total_tokens}`); // Track costs (example rates) const cost = ( usage.prompt_tokens * 0.00001 + usage.completion_tokens * 0.00002 ).toFixed(4); console.log(`Estimated cost: $${cost}`); ``` Token usage varies by model. Larger models (like GPT-5) cost more per token than smaller models (like GPT-4o-mini). ## Examples ### Summarization Service ```typescript export default { async fetch(request, env) { if (request.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } const { text } = await request.json(); const response = await fetch(`${env.PLOY_AI_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.PLOY_AI_TOKEN}`, }, body: JSON.stringify({ model: "glm-4.6v-flash", messages: [ { role: "system", content: "You are a summarization assistant. Provide concise summaries.", }, { role: "user", content: `Summarize the following text:\n\n${text}`, }, ], max_tokens: 150, }), }); const data = await response.json(); const summary = data.choices[0].message.content; return new Response(JSON.stringify({ summary }), { headers: { "Content-Type": "application/json" }, }); }, }; ``` ## Next Steps * [Workers Guide](https://docs.meetploy.com/features/workers) - Learn more about building workers * [Configuration](https://docs.meetploy.com/configuration) - Configure your Ploy project * [LangChain Documentation](https://js.langchain.com/) - Explore LangChain features # Authentication URL: https://docs.meetploy.com/features/auth # Authentication Ploy Auth provides managed user authentication for your deployed applications. Add email/password authentication with a single configuration option. ## Configuration Add an auth binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist auth: binding: AUTH_DB ``` The `binding` specifies which database to use for storing users. Ploy automatically creates the necessary tables when you deploy. ## How It Works When you enable auth, Ploy: 1. Creates or uses an existing database based on the binding name 2. Initializes `ploy_users` and `ploy_sessions` tables 3. Exposes auth endpoints at `/_ploy/auth/*` for browser clients 4. Provides an internal `http://auth/` URL for server-side access 5. Generates a per-project JWT secret for signing tokens ## Auth Endpoints All endpoints are available at `/_ploy/auth/*`: | Endpoint | Method | Description | | ---------- | ------ | ------------------------------- | | `/signup` | POST | Create a new user account | | `/signin` | POST | Sign in with email and password | | `/signout` | POST | Revoke the current session | | `/me` | GET | Get the current user | ### Sign Up ```typescript const response = await fetch("/_ploy/auth/signup", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "user@example.com", password: "securepassword123", metadata: { name: "John Doe" }, // optional }), }); const { user } = await response.json(); // Session cookie is automatically set ``` ### Sign In ```typescript const response = await fetch("/_ploy/auth/signin", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "user@example.com", password: "securepassword123", }), }); const { user } = await response.json(); // Session cookie is automatically set ``` ### Get Current User ```typescript const response = await fetch("/_ploy/auth/me", { credentials: "include", }); const { user } = await response.json(); ``` ### Sign Out ```typescript await fetch("/_ploy/auth/signout", { method: "POST", credentials: "include", }); // Session cookie is cleared ``` ## Server-Side Authentication In your server code, use the `PLOY_AUTH` binding to verify tokens and get user information: ```typescript title="app/api/protected/route.ts" export async function GET(request: Request) { const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); if (!token) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } // Use the auth binding to verify and get user const user = await env.PLOY_AUTH.getUser(token); if (!user) { return Response.json({ error: "Invalid token" }, { status: 401 }); } return Response.json({ user }); } ``` ### Server-Side Auth Methods The `PLOY_AUTH` binding provides: ```typescript interface PloyAuth { // Get user from session token getUser(token: string): Promise; // Verify token signature without fetching user verifyToken(token: string): Promise; } interface PloyUser { id: string; email: string; emailVerified: boolean; createdAt: string; metadata: Record | null; } ``` ## Session Management Sessions are managed via secure httpOnly cookies: * **Session tokens**: Valid for 7 days * **Cookie name**: `ploy_session` * **Cookie flags**: httpOnly, SameSite=Lax, Secure (in production) Session tokens are stored in httpOnly cookies, preventing XSS attacks. The cookie is automatically sent with every request when using `credentials: "include"`. ## Security Features Ploy Auth includes built-in security measures: * **Password hashing**: PBKDF2 with 100,000 iterations and SHA-512 * **JWT signing**: HMAC-SHA256 with per-project secrets * **Session expiration**: 7-day session tokens with revocation support * **Email validation**: Server-side email format validation * **Password requirements**: Minimum 8 characters * **Secure cookies**: httpOnly cookies prevent XSS attacks ## User Metadata Store additional user data in the `metadata` field during signup: ```typescript const response = await fetch("/_ploy/auth/signup", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "user@example.com", password: "securepassword123", metadata: { name: "John Doe", company: "Acme Inc", plan: "pro", }, }), }); ``` Metadata is returned with the user object on signin and when calling `/me`. ## React Components For Next.js applications, use the `@meetploy/auth-react` package for pre-built components: ```bash pnpm add @meetploy/auth-react ``` See the [Next.js Auth Guide](https://docs.meetploy.com/start/auth) for detailed integration instructions. ## Next Steps * [Next.js Auth Guide](https://docs.meetploy.com/start/auth) - Integrate auth in Next.js with React components * [Databases](https://docs.meetploy.com/features/db) - Learn about database bindings * [Workers](https://docs.meetploy.com/features/workers) - Understand Ploy workers # Cache URL: https://docs.meetploy.com/features/cache # Cache Ploy Cache provides a fast, ephemeral key-value store backed by Redis. Unlike [State](https://docs.meetploy.com/features/state), which is durable, Cache is meant for transient data such as computed results, rate-limit counters, or short-lived session data. Each entry can optionally expire after a TTL. Cache is best-effort, not durable storage. TTLs are not guaranteed and entries may be evicted at any time — for example under memory pressure — even before their TTL elapses. Always handle a `null` result from `get()`, and never rely on cached data being present. For data that must persist, use [State](https://docs.meetploy.com/features/state). ## Configuration Add a cache binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist cache: CACHE: default ``` The key (`CACHE`) is the binding name available in your worker's `env`. The value (`default`) is the cache store identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { CacheBinding } from "@meetploy/types"; export interface Env { CACHE: CacheBinding; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/set") { // Cache for 60 seconds await env.CACHE.set("greeting", "hello", { ttl: 60 }); return Response.json({ success: true }); } if (url.pathname === "/get") { const value = await env.CACHE.get("greeting"); return Response.json({ value }); } return new Response("Cache Worker"); }, } satisfies Ploy; ``` ## API Methods ### get(key) - Get a Value Returns the stored string value, or `null` if the key doesn't exist or has expired. ```typescript const value = await env.CACHE.get("my-key"); // value is string | null ``` ### set(key, value, options?) - Store a Value Stores a string value, overwriting any existing value for the key. Pass an optional `ttl` (in seconds) to make the entry expire automatically. Without a `ttl`, the value is stored until it is overwritten or evicted by Redis. ```typescript // Store without expiry await env.CACHE.set("username", "alice"); // Store for 5 minutes await env.CACHE.set("session:abc", token, { ttl: 300 }); ``` ## Common Patterns ### Caching Expensive Computations ```typescript async function getReport(env: PloyEnv, id: string) { const cached = await env.CACHE.get(`report:${id}`); if (cached) { return JSON.parse(cached); } const report = await computeExpensiveReport(id); // Cache for 10 minutes await env.CACHE.set(`report:${id}`, JSON.stringify(report), { ttl: 600 }); return report; } ``` ### Rate Limiting ```typescript async function isRateLimited(env: PloyEnv, ip: string): Promise { const key = `ratelimit:${ip}`; const seen = await env.CACHE.get(key); if (seen) { return true; } // Block repeat requests from the same IP for 1 second await env.CACHE.set(key, "1", { ttl: 1 }); return false; } ``` ### Short-Lived Session Data ```typescript // Store a one-time token for 15 minutes await env.CACHE.set(`otp:${userId}`, code, { ttl: 900 }); // Later, validate it const stored = await env.CACHE.get(`otp:${userId}`); const valid = stored !== null && stored === submittedCode; ``` ## Multiple Cache Bindings You can configure multiple cache stores for different use cases: ```yaml title="ploy.yaml" cache: CACHE: default SESSION_CACHE: sessions ``` ```typescript await env.CACHE.set("config", value, { ttl: 60 }); await env.SESSION_CACHE.set(`user:${id}`, sessionData, { ttl: 3600 }); ``` ## Next Steps * [State](https://docs.meetploy.com/features/state) - Durable key-value storage * [Databases](https://docs.meetploy.com/features/db) - Add persistent SQLite databases * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Cron Triggers URL: https://docs.meetploy.com/features/cron # Cron Triggers Ploy Cron Triggers let you schedule your workers to run automatically on a recurring basis. Define cron expressions in your `ploy.yaml` and implement a `scheduled` handler to process them. ## Configuration Add cron triggers in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist cron: EVERY_10_SECONDS: "*/10 * * * * *" EVERY_MINUTE: "* * * * *" HOURLY_CLEANUP: "0 * * * *" DAILY_REPORT: "0 9 * * *" ``` Each key is a trigger name (uppercase with underscores) and the value is a cron expression. The standard form has 5 fields: ``` # ┌───────────── minute (0-59) # │ ┌───────────── hour (0-23) # │ │ ┌───────────── day of month (1-31) # │ │ │ ┌───────────── month (1-12) # │ │ │ │ ┌───────────── day of week (0-6, 0=Sunday) # │ │ │ │ │ # * * * * * ``` For sub-minute schedules, use the extended 6-field form with a leading seconds field: ``` # ┌───────────── second (0-59) # │ ┌───────────── minute (0-59) # │ │ ┌───────────── hour (0-23) # │ │ │ ┌───────────── day of month (1-31) # │ │ │ │ ┌───────────── month (1-12) # │ │ │ │ │ ┌───────────── day of week (0-6, 0=Sunday) # │ │ │ │ │ │ # * * * * * * ``` A 5-field expression fires at second 0 of every matching minute, so existing schedules behave exactly as before. ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { return new Response("Hello from cron worker!"); }, async scheduled(event) { console.log(`Cron triggered: ${event.cron}`); console.log( `Scheduled time: ${new Date(event.scheduledTime).toISOString()}`, ); // Your scheduled logic here await performCleanup(); }, } satisfies Ploy; ``` ## ScheduledEvent The `scheduled` handler receives a `ScheduledEvent` with the following properties: ```typescript interface ScheduledEvent { /** The cron expression that triggered this execution */ cron: string; /** Timestamp when the event was scheduled (ms since epoch) */ scheduledTime: number; /** Call to signal this invocation should not be retried */ noRetry: () => void; } ``` ## Cron Expression Syntax Standard 5-field cron expressions and extended 6-field expressions (with a leading seconds field) are supported: | Expression | Description | | ---------------- | ------------------------ | | `*/10 * * * * *` | Every 10 seconds | | `*/30 * * * * *` | Every 30 seconds | | `0 * * * * *` | Every minute at second 0 | | `* * * * *` | Every minute | | `*/5 * * * *` | Every 5 minutes | | `0 * * * *` | Every hour | | `0 0 * * *` | Every day at midnight | | `0 9 * * 1-5` | Weekdays at 9 AM | | `0 0 1 * *` | First day of every month | | `30 2 * * 0` | Sundays at 2:30 AM | ### Supported Syntax * **Wildcards**: `*` matches all values * **Specific values**: `5` matches exactly 5 * **Ranges**: `1-5` matches 1 through 5 * **Steps**: `*/15` matches every 15th value * **Lists**: `1,3,5` matches 1, 3, and 5 * **Combined**: `1-10/2` matches 1, 3, 5, 7, 9 * **Seconds**: prefix with a sixth leading field for sub-minute schedules (e.g. `*/10 * * * * *`) ## Multiple Triggers You can define multiple cron triggers, each identified by a unique name: ```yaml title="ploy.yaml" cron: CLEANUP: "0 * * * *" DAILY_DIGEST: "0 9 * * *" WEEKLY_REPORT: "0 10 * * 1" ``` Use the `event.cron` property to distinguish which trigger fired: ```typescript async scheduled(event) { switch (event.cron) { case "0 * * * *": await runCleanup(); break; case "0 9 * * *": await sendDailyDigest(); break; case "0 10 * * 1": await generateWeeklyReport(); break; } } ``` ## Using with Next.js Next.js apps deploy the framework server as the worker module, so the `scheduled` handler comes from your Ploy handlers file instead. Export it from `ploy.ts` (project root, `src/`, or `app/`): ```typescript title="app/ploy.ts" import type { Ploy } from "@meetploy/types"; export default { async scheduled(event, env, ctx) { console.log(`Cron triggered: ${event.cron}`); // Your scheduled logic here }, } satisfies Ploy; ``` If your project pairs the Next.js frontend with a worker entry at `worker/index.ts` (the same convention the Vite integration uses), its default export is picked up automatically when no `ploy.ts` exists — a `scheduled` handler exported there receives cron triggers in production too. ## Using with the Start SDK If you're using `@meetploy/start`, register a scheduled handler with `.scheduled()`: ```typescript title="src/index.ts" import { ploy } from "@meetploy/start"; const app = ploy() .get( "/", { response: z.object({ status: z.string() }), }, () => ({ status: "ok" }), ) .scheduled(async (event, env, ctx) => { console.log(`Cron: ${event.cron}`); // Your scheduled logic }) .build(); export default app; ``` ## Combining with Other Bindings Cron triggers work alongside other Ploy bindings. Use queues, databases, and caches from your scheduled handler: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist db: DB: default queue: TASKS: tasks cron: HOURLY_SYNC: "0 * * * *" ``` ```typescript async scheduled(event, env) { // Query your database const staleRecords = await env.DB.prepare( "SELECT id FROM records WHERE updated_at < ?" ).bind(Date.now() - 86400000).all(); // Queue work for processing for (const record of staleRecords.results) { await env.TASKS.send({ action: "refresh", recordId: record.id }); } } ``` The `scheduled` handler has access to the same `env` bindings as your `fetch` handler. ## Local Development When running the emulator with `ploy dev`, cron triggers are automatically scheduled based on your `ploy.yaml` configuration. The emulator checks cron expressions every second, so sub-minute (6-field) schedules fire on time, and invokes your `scheduled` handler when a match occurs. Cron execution history is visible in the dev dashboard. ## Next Steps * [Queues](https://docs.meetploy.com/features/queues) - Process background jobs * [Workflows](https://docs.meetploy.com/features/workflows) - Orchestrate multi-step processes * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Databases URL: https://docs.meetploy.com/features/db # Databases Ploy supports SQLite databases for workers and applies project migrations automatically. Each project gets its own isolated database that persists across deployments. ## Configuration Add a database binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist db: DB: default ``` The key (`DB`) is the binding name available in your worker's `env`. The value (`default`) is the database identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { Database } from "@meetploy/types"; export interface Env { DB: Database; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { // Assumes the users table exists from your migrations await env.DB.prepare("INSERT INTO users (name) VALUES (?)") .bind("Alice") .run(); // Query const { results } = await env.DB.prepare("SELECT * FROM users").all(); return Response.json({ users: results }); }, } satisfies Ploy; ``` ## Migrations Create project migrations in `migrations/`. Ploy runs them in filename order and tracks applied files in `ploy_internal_db_migrations` inside the same database. For a single DB binding, you can keep migrations at the root: ```text title="migrations/" migrations/ ├── 001_create_users.sql └── 20260403171410_add_posts.sql ``` Nested migration folders are also supported. Any `.sql` file in the folder is executed, and sibling files like `snapshot.json` are ignored: ```text title="migrations/" migrations/ └── 20260403171410_init/ ├── migration.sql └── snapshot.json ``` If your project uses multiple DB bindings, scope migrations by binding name: ```text title="migrations/" migrations/ ├── DB/ │ └── 20260403171410_init/ │ └── migration.sql └── ANALYTICS_DB/ └── 001_create_events.sql ``` Production deploys on the default branch apply pending migrations before the deployment is uploaded, and the applied files are logged in the build output. During local development, `ploy dev` applies project migrations on startup. ## Query Methods ### all() - Get All Rows ```typescript const { results } = await env.DB.prepare("SELECT * FROM users").all(); ``` ### first() - Get First Row ```typescript const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?") .bind(1) .first(); ``` ### run() - Execute Statement ```typescript const result = await env.DB.prepare("INSERT INTO users (name) VALUES (?)") .bind("Bob") .run(); console.log(result.meta.rows_written); // 1 ``` ### exec() - Run Raw SQL ```typescript const result = await env.DB.exec(` CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT); CREATE INDEX IF NOT EXISTS idx_title ON posts(title); `); console.log(result.count); // number of statements executed ``` ### withSession() - Session Compatibility Ploy exposes `withSession()` so code that expects a session object can keep using `prepare()` and `batch()` unchanged. ```typescript const session = env.DB.withSession("first-primary"); const user = await session .prepare("SELECT * FROM users WHERE id = ?") .bind(1) .first(); ``` ## TypeScript Support Add generics for type-safe queries: ```typescript interface User { id: number; name: string; } const users = await env.DB.prepare("SELECT * FROM users").all(); // users.results is User[] const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?") .bind(1) .first(); // user is User | null ``` Always use prepared statements with `.bind()` to prevent SQL injection. ## Limits Every statement is checked before it runs, both in production and in `ploy dev`, so oversized queries fail the same way locally as they do once deployed: | Limit | Default | | ------------------------------ | ------- | | SQL text per statement | 100 KB | | Bound parameters per statement | 100 | | Statements per `batch()` | 100 | | SQL text per `exec()` script | 1 MB | | Statements per `exec()` script | 1000 | Exceeding a limit rejects the request before it reaches SQLite — a `batch()` that breaks a limit applies none of its statements. The rejection is thrown in your worker with the reason attached, for example `DB batch failed: Statement with 301 bound params exceeds DB_MAX_SQL_BOUND_PARAMS (100)`, so the offending call is identifiable from the error alone. `exec()` gets its own, roomier caps because migrations run as a single script holding many statements. Each statement inside the script is still held to the per-statement limits, and migrations are checked during `ploy dev` too, so an oversized migration fails locally rather than at deploy time. Self-hosted deployments can raise or lower each cap with the `DB_MAX_SQL_LENGTH_BYTES`, `DB_MAX_SQL_BOUND_PARAMS`, `DB_MAX_BATCH_STATEMENTS`, `DB_MAX_EXEC_LENGTH_BYTES`, and `DB_MAX_EXEC_STATEMENTS` environment variables. Set the same values for the emulator to mirror a tuned deployment during development. ## Next Steps * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers * [Queues](https://docs.meetploy.com/features/queues) - Add background job processing # Environment Variables URL: https://docs.meetploy.com/features/env-vars # Environment Variables Environment variables are not declared anywhere. Every variable you set in the Ploy dashboard is available to your build and to your worker at runtime under `env.vars`, and during local development the same applies to every entry of your project's `.env` file. `ploy.yaml` used to have an `env` section. It has been removed — frameworks like Next.js inject variables through their own mechanisms, and libraries you depend on can read variables you never wrote down. Declaring every variable up front only got in the way. ## Accessing Variables All variables are available under `env.vars` in your worker: ```typescript title="src/index.ts" export default { async fetch(request, env) { return Response.json({ appName: env.vars.APP_NAME, apiUrl: env.vars.API_URL, }); }, } satisfies Ploy; ``` In a Next.js app you keep using `process.env` as usual — Ploy's Next.js adapter injects the variables for you: ```typescript title="app/api/config/route.ts" export async function GET() { return Response.json({ apiUrl: process.env.API_URL }); } ``` `ploy types` generates a `vars` property typed as `Record`. Because variables are never declared, the CLI cannot know their names: ```typescript title="env.d.ts" declare global { interface PloyEnv { vars: Record; } } ``` ## Local Development with `.env` Create a `.env` file in your project directory: ```bash title=".env" APP_NAME=my-app SECRET_KEY=my-local-secret API_URL=http://localhost:3000/api ``` When you run `ploy dev`, every entry of that file is loaded and exposed under `env.vars`. Add `.env` to your `.gitignore` file. Never commit secrets to version control. ## Production: Dashboard Variables In production, `env.vars` is populated from the environment variables set on your project. The same values are also exported into the build environment, so they are available to `next build` and any other build step. ### Setting Variables in the Dashboard 1. Go to your project settings in the Ploy dashboard 2. Navigate to the **Environment Variables** section 3. Add the variables your project needs Variables can be added one at a time, or pasted in bulk in `KEY=value` form — for example straight from your local `.env` file. ### Secret vs Plaintext When adding variables in the dashboard, you can choose between: * **Secret**: The value is encrypted and never displayed after creation. Use for API keys, tokens, and passwords. * **Plaintext**: The value is stored and displayed in plain text. Use for non-sensitive configuration. ## Variables Ploy Sets For You Ploy injects a few variables of its own into every deployment: | Variable | Description | | -------------------- | ----------------------------------------------------------------------- | | `PLOY_URL` | The project's primary URL | | `PLOY_SHA_URL` | The immutable URL for this commit | | `PLOY_BRANCH_URL` | The URL for this branch | | `PLOY_LIVE_URL` | The URL of the current production deployment | | `PLOY_BRANCH` | The branch this deployment was built from | | `PLOY_COMMIT` | The commit this deployment was built from | | `PLOY_DEPLOYMENT_ID` | This deployment's id (see [skew protection](https://docs.meetploy.com/features/skew-protection)) | ## Full Example ### Project Structure ``` my-worker/ ├── .env # Local values (gitignored) ├── ploy.yaml # Project configuration ├── env.d.ts # Generated types ├── package.json ├── tsconfig.json └── src/ └── index.ts ``` ### ploy.yaml ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist ``` ### .env ```bash title=".env" APP_NAME=my-app SECRET_KEY=dev-secret-key-123 API_URL=https://api.dev.example.com ``` ### src/index.ts ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/config") { return Response.json({ appName: env.vars.APP_NAME, apiUrl: env.vars.API_URL, // Never expose secrets in responses! }); } // Use SECRET_KEY for authentication const authHeader = request.headers.get("Authorization"); if (authHeader !== `Bearer ${env.vars.SECRET_KEY}`) { return new Response("Unauthorized", { status: 401 }); } return Response.json({ message: "Authenticated" }); }, } satisfies Ploy; ``` ## Next Steps * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers * [Configuration](https://docs.meetploy.com/configuration) - Full `ploy.yaml` reference * [Databases](https://docs.meetploy.com/features/db) - Add persistent SQLite databases # File Storage URL: https://docs.meetploy.com/features/file-storage # File Storage Ploy File Storage provides an S3/R2-like object store for your workers. It is designed for storing and retrieving file content such as user uploads, images, JSON documents, and any binary or text data. Unlike state and cache which store simple string values, file storage supports content types, file listing, and is optimized for larger payloads. ## Configuration Add a file storage binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist fs: FILES: default ``` The key (`FILES`) is the binding name available in your worker's `env`. The value (`default`) is the file storage identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { FileStorageBinding } from "@meetploy/types"; export interface Env { FILES: FileStorageBinding; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/upload" && request.method === "POST") { const body = await request.text(); await env.FILES.put("hello.txt", body, { contentType: "text/plain" }); return Response.json({ success: true }); } if (url.pathname === "/download") { const file = await env.FILES.get("hello.txt"); if (!file) { return new Response("Not Found", { status: 404 }); } return new Response(file.body, { headers: { "Content-Type": file.contentType }, }); } if (url.pathname === "/delete") { await env.FILES.delete("hello.txt"); return Response.json({ success: true }); } return new Response("File Storage Worker"); }, } satisfies Ploy; ``` ## API Methods ### put(key, value, options?) - Store a File Stores a file with the given key. Overwrites any existing file at the same key. The optional `options` parameter lets you specify a content type. ```typescript await env.FILES.put("report.pdf", pdfBuffer, { contentType: "application/pdf", }); // Content type defaults to "application/octet-stream" if not specified await env.FILES.put("data.bin", binaryData); ``` ### get(key) - Retrieve a File Returns the file object or `null` if the key doesn't exist. The returned object includes `body` (the file content) and `contentType`. ```typescript const file = await env.FILES.get("report.pdf"); // file is { body: ReadableStream | string, contentType: string } | null if (file) { console.log(file.contentType); // "application/pdf" // Use file.body for the content } ``` ### delete(key) - Delete a File Removes a file from the store. ```typescript await env.FILES.delete("report.pdf"); ``` ### list(options?) - List Files Returns a list of keys in the store. Use the optional `prefix` parameter to filter results. ```typescript // List all files const allFiles = await env.FILES.list(); // allFiles is { keys: string[] } // List files under a prefix const userFiles = await env.FILES.list({ prefix: "uploads/user-123/" }); ``` File storage supports any content type. Use the `contentType` option in `put()` to ensure files are served with the correct MIME type when retrieved. ## Common Patterns ### Storing User Uploads ```typescript async function handleUpload( request: Request, env: PloyEnv, userId: string, ): Promise { const contentType = request.headers.get("Content-Type") || "application/octet-stream"; const filename = request.headers.get("X-Filename") || "file"; const key = `uploads/${userId}/${Date.now()}-${filename}`; const body = await request.arrayBuffer(); await env.FILES.put(key, body, { contentType }); return Response.json({ key }); } ``` ### Storing JSON Documents ```typescript // Store a JSON document const config = { theme: "dark", layout: "grid", version: 2 }; await env.FILES.put("config/app.json", JSON.stringify(config), { contentType: "application/json", }); // Retrieve and parse const file = await env.FILES.get("config/app.json"); if (file) { const config = JSON.parse(file.body); } ``` ### Organizing Files with Prefixes ```typescript // Store files under organized prefixes await env.FILES.put("images/avatars/user-123.png", avatarData, { contentType: "image/png", }); await env.FILES.put("images/avatars/user-456.png", avatarData, { contentType: "image/png", }); await env.FILES.put("documents/invoices/inv-001.pdf", invoiceData, { contentType: "application/pdf", }); // List all avatars const avatars = await env.FILES.list({ prefix: "images/avatars/" }); // avatars.keys = ["images/avatars/user-123.png", "images/avatars/user-456.png"] // List all documents const docs = await env.FILES.list({ prefix: "documents/" }); ``` ### Serving Files with Correct Headers ```typescript async function serveFile(env: PloyEnv, key: string): Promise { const file = await env.FILES.get(key); if (!file) { return new Response("Not Found", { status: 404 }); } return new Response(file.body, { headers: { "Content-Type": file.contentType, "Cache-Control": "public, max-age=3600", }, }); } ``` ## Multiple File Storage Bindings You can configure multiple file stores for different use cases: ```yaml title="ploy.yaml" fs: FILES: default MEDIA: media ``` ```typescript // General file storage await env.FILES.put("data/export.csv", csvContent, { contentType: "text/csv", }); // Media-specific storage await env.MEDIA.put("images/hero.jpg", imageData, { contentType: "image/jpeg", }); ``` ## Differences from State | | File Storage | State | | ---------------- | ----------------------------------- | ----------------------------- | | **Designed for** | File content (binary/text) | Durable key-value data | | **Content type** | Supports `contentType` metadata | Text, JSON, binary, streams | | **Listing** | `list()` with prefix filtering | `list()` with KV-style cursor | | **TTL** | None (persists until deleted) | Optional expiration TTL | | **Use case** | Uploads, documents, images, exports | Preferences, flags, counters | ## Next Steps * [State](https://docs.meetploy.com/features/state) - Add durable key-value storage * [Databases](https://docs.meetploy.com/features/db) - Add persistent SQLite databases * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Monorepos URL: https://docs.meetploy.com/features/monorepos # Monorepos Ploy detects monorepos automatically — there is no `monorepo: true` flag. A repository is treated as a monorepo when it contains workspace indicators (`pnpm-workspace.yaml`, `package.json` with `workspaces`, `turbo.json`, etc.) **or** when more than one `ploy.yaml` is present. Each `ploy.yaml` becomes its own deployable project. You can keep an `apps/api`, an `apps/web`, and a `packages/worker-jobs` in the same repo, deploy them as three independent Ploy projects, and share resources (databases, queues, workflows) between them by pointing them at the same resource name. ## Layout ``` my-app/ ├── ploy-workspace.yaml # optional — workspace-level config ├── apps/ │ ├── api/ │ │ └── ploy.yaml # → project "api" │ └── web/ │ └── ploy.yaml # → project "web" ├── packages/ │ └── worker-jobs/ │ └── ploy.yaml # → project "worker-jobs" ├── package.json └── pnpm-workspace.yaml ``` Each `ploy.yaml` is a complete, self-contained config. There is no merge with a parent file. Project names default to the parent directory; override with the `name:` field if you need something different. ```yaml title="apps/api/ploy.yaml" kind: worker name: api # optional — defaults to "api" build: pnpm build --filter api db: DB: default # shares the "default" DB with any other project that names it queue: TASKS: tasks ``` ## Sharing resources between projects Resources are addressed by their **resource name** (the lowercase string on the right side of the binding map). Two projects that bind to the same resource name read and write the same underlying resource — locally and in production. ```yaml title="apps/api/ploy.yaml" db: DB: default ``` ```yaml title="apps/web/ploy.yaml" db: DB: default # same DB as apps/api ``` To keep separate resources, use distinct names: ```yaml # apps/api/ploy.yaml db: DB: api_db # apps/web/ploy.yaml db: DB: web_db ``` Only one project may own migrations for a shared resource. If two projects both ship `migrations/DB/*.sql` for the same resource name, Ploy errors out with both project names so you can pick a single owner. ## `ploy-workspace.yaml` A workspace config at the repo root is **optional**. Add one when you need to: * Exclude `ploy.yaml` files that aren't deployable (e.g., examples, fixtures, templates) * Set workspace-level dev port ranges * Pin the local dashboard port ```yaml title="ploy-workspace.yaml" exclude: - examples/** - templates/** ports: worker: from: 8800 # auto-allocate worker ports starting here dashboard: port: 9787 ``` `node_modules/` is always excluded by default. ## Local development Run `ploy dev` from the **repo root** to bring up all projects together: ```bash $ ploy dev Ploy workspace dev Dashboard: http://localhost:9787 api worker http://localhost:8787 web worker http://localhost:8788 ``` What this gives you: * **One shared `.ploy/` directory** at the repo root — all SQLite DBs, file storage, and emulator state live here. Resources with the same name across projects share the same files, exactly as in production. * **One shared dashboard** at `http://localhost:9787` exposing every project's bindings and the resources they touch. * **One workerd per project** on auto-allocated ports starting at `8787`. Override per-project with `dev: { port: 8800 }` in that project's `ploy.yaml`. * **Single `Ctrl-C`** stops everything cleanly. To run a subset: ```bash $ ploy dev --project=api $ ploy dev --project=api,web ``` Running `ploy dev` from inside a single project's directory still works the old way: that project alone is started with its own `.ploy/` and dashboard. This preserves backward compatibility for repos that aren't yet set up as workspaces. ## Cloud deployments When you connect a GitHub repository, Ploy scans it for `ploy.yaml` files (using the GitHub API — no clone needed) and shows the detected list in the dashboard. You select which entries to enable as projects. Each row defaults its name to the parent directory; override inline. After the initial connect, pushes to the default branch are diffed against the enabled set: * A `ploy.yaml` that already maps to a project triggers a build for **just that project**. * A new `ploy.yaml` that wasn't previously enabled appears as a "new project detected" entry. Nothing deploys until you explicitly enable it. * A `ploy.yaml` that's removed from the repo leaves the corresponding project untouched (archive or delete it manually if no longer needed). This means adding a deploy target to an existing monorepo is just `git add apps/billing/ploy.yaml && git push` — Ploy will surface the new project for confirmation rather than auto-deploying. ## How `base` interacts with monorepos In a monorepo, the build runs from the project's directory (the directory containing its `ploy.yaml`). Dependencies are installed from the **repo root** so workspace packages resolve. You don't need to set `base:` — it's inferred from the `ploy.yaml` location. The `base:` field is only required if your `ploy.yaml` lives at the **repo root** but the deployable code lives in a subdirectory. In that case `base:` tells the builder where to run the build: ```yaml title="ploy.yaml at repo root" kind: nextjs base: apps/web build: pnpm build --filter web ``` For per-project `ploy.yaml` files in subdirectories, omit `base:` — it's inferred. ## Migration from `monorepo: true` The `monorepo: true` flag has been removed from `ploy.yaml`. Remove it from your config files; Ploy now detects monorepos automatically from workspace indicators in your repo. If you previously had: ```yaml title="apps/web/ploy.yaml (old)" kind: nextjs base: apps/web monorepo: true build: pnpm build --filter web ``` Update to (drop both `monorepo` and `base` — the latter is inferred from the file's location): ```yaml title="apps/web/ploy.yaml (new)" kind: nextjs build: pnpm build --filter web ``` A validation error will point out any remaining `monorepo:` keys when you next run `ploy build` or push to a connected repo. # Queues URL: https://docs.meetploy.com/features/queues # Queues Ploy Queues let you send messages to be processed asynchronously by your worker. Messages are durably stored and delivered at least once. ## Configuration Add a queue binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist queue: TASKS: tasks ``` The key (`TASKS`) is the binding name available in your worker's `env`. The value (`tasks`) is the queue identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { QueueBinding } from "@meetploy/types"; export interface Env { TASKS: QueueBinding; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { // Send a message to the queue const { messageId } = await env.TASKS.send({ task: "process-order", orderId: "123", }); return Response.json({ messageId }); }, // Handle incoming messages async message(event) { console.log("Processing:", event.payload); }, } satisfies Ploy; ``` ## Sending Messages ### Single Message ```typescript const { messageId } = await env.TASKS.send({ task: "process" }); ``` ### Delayed Message ```typescript const { messageId } = await env.TASKS.send( { task: "reminder" }, { delaySeconds: 60 }, ); ``` ### Batch Send ```typescript const { messageIds } = await env.TASKS.sendBatch([ { payload: { task: "batch-1" } }, { payload: { task: "batch-2" } }, { payload: { task: "batch-3" } }, ]); ``` ## Message Handler The `message` handler receives a `QueueMessageEvent`: ```typescript async message(event) { console.log({ id: event.id, queue: event.queueName, payload: event.payload, attempt: event.attempt, }); } ``` Messages that throw an error are automatically retried with exponential backoff. ## Next Steps * [Workflows](https://docs.meetploy.com/features/workflows) - Orchestrate multi-step processes * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Sandboxes URL: https://docs.meetploy.com/features/sandboxes # Sandboxes Ploy Sandboxes give your workers an isolated, long-lived environment to run shell commands, read and write files, clone repositories, and stream output. Each sandbox is identified by an id, persists across requests, and is launched lazily on its first use — perfect for build steps, code execution, and AI agents. ## Configuration Add a sandbox binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist sandbox: SANDBOX: default ``` The key (`SANDBOX`) is the binding name available in your worker's `env`. The value (`default`) is the sandbox identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { SandboxBinding } from "@meetploy/types"; export interface Env { SANDBOX: SandboxBinding; } ``` Call `env.SANDBOX.get(id)` to get a client scoped to one sandbox. Reusing the same `id` across requests resolves the same sandbox, so its files and state persist between calls. ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); const id = url.searchParams.get("id") ?? "default"; const sandbox = env.SANDBOX.get(id); // Run a command if (url.pathname === "/exec") { const result = await sandbox.exec("echo hello from ploy sandbox"); return Response.json(result); } // Write then read a file back if (url.pathname === "/file") { await sandbox.writeFile("note.txt", "written by the worker\n"); const { content } = await sandbox.readFile("note.txt"); return Response.json({ content }); } return new Response("Sandbox Worker"); }, } satisfies Ploy; ``` ## API Methods ### exec(command, options?) — Run a Command Runs a command and resolves once it finishes, returning its output and exit status. ```typescript const result = await sandbox.exec("ls -la", { cwd: "/tmp", env: { GREETING: "hi" }, timeoutMs: 5000, }); console.log(result.stdout); console.log(result.exitCode); console.log(result.success); ``` The result is a `SandboxExecResult` with `stdout`, `stderr`, `exitCode`, and a `success` boolean. ### execStream(command, options?) — Stream Command Output Returns a `ReadableStream` of the command's output as it runs. Useful for long-running tasks like build logs or agent output. ```typescript const stream = await sandbox.execStream("npm install"); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); ``` ### writeFile(path, content) / readFile(path) — Files ```typescript await sandbox.writeFile("config.json", JSON.stringify({ debug: true })); const { content } = await sandbox.readFile("config.json"); ``` ### deleteFile(path) — Remove a File ```typescript await sandbox.deleteFile("config.json"); ``` ### mkdir(path, options?) — Create a Directory ```typescript await sandbox.mkdir("/data/nested", { recursive: true }); ``` ### listFiles(path) — List Directory Contents Returns an array of entries, each with a `name`, `path`, `type` (`"file"` or `"directory"`), and optional `size`. ```typescript const entries = await sandbox.listFiles("."); for (const entry of entries) { console.log(entry.type, entry.name); } ``` ### gitCheckout(repoUrl, options?) — Clone a Repository ```typescript await sandbox.gitCheckout("https://github.com/owner/repo", { branch: "main", depth: 1, targetDir: "repo", }); ``` ### setEnvVars(vars) — Set Environment Variables Sets environment variables that apply to subsequent commands in the sandbox. ```typescript await sandbox.setEnvVars({ NODE_ENV: "production" }); ``` ### destroy() — Tear Down the Sandbox Destroys the sandbox and its contents. ```typescript await sandbox.destroy(); ``` ## Background Processes `exec()` and `execStream()` block until the command finishes, so they tie up your worker request for the whole run. For long-running work — a dev server, a build, a coding agent that runs for minutes — use `startProcess()` instead. It launches the command in the background, returns **immediately** with a process handle, and lets the process keep running after your worker has responded. There is no completion callback: you start a process, persist its `id`, and check back on it from later requests by polling `getProcess()` or streaming its logs. ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); const sandbox = env.SANDBOX.get("builder"); // Kick off a long job and return its handle right away. if (url.pathname === "/build/start") { const proc = await sandbox.startProcess("npm run build"); return Response.json(proc); // → { id, pid, command, status: "running", exitCode: null, startedAt } } // Later request: check whether it finished. if (url.pathname === "/build/status") { const id = url.searchParams.get("id"); const proc = await sandbox.getProcess(id); return Response.json(proc); } return new Response("Builder"); }, } satisfies Ploy; ``` A `SandboxProcessInfo` handle looks like: ```typescript interface SandboxProcessInfo { id: string; // process id — persist this to check back later pid: number | null; // process group id inside the sandbox command: string; status: "running" | "completed" | "failed"; exitCode: number | null; // set once the process finishes startedAt: string; // ISO timestamp } ``` ### startProcess(command, options?) — Start in the Background Same `options` as `exec()` (`cwd`, `env`, `timeoutMs`). Returns as soon as the process is launched. ```typescript const proc = await sandbox.startProcess("python worker.py", { cwd: "/app", env: { WORKERS: "4" }, }); ``` ### listProcesses() / getProcess(id) — Inspect ```typescript const all = await sandbox.listProcesses(); const proc = await sandbox.getProcess(id); if (proc?.status === "completed") { // done } ``` ### getProcessLogs(id) — Read Accumulated Output Returns the process's combined stdout and stderr captured so far. ```typescript const { logs } = await sandbox.getProcessLogs(id); ``` ### streamProcessLogs(id) — Stream Output Live Replays the log from the start, follows it as new output arrives, and ends when the process exits. ```typescript const stream = await sandbox.streamProcessLogs(id); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); ``` ### killProcess(id) — Terminate Stops the process and its child tree. ```typescript await sandbox.killProcess(id); ``` A sandbox is not suspended for idleness while a background process is still running. Background processes do not survive a sandbox being stopped or destroyed, so treat them as living for the lifetime of the sandbox. ## Sessions A session is a persistent execution context within a sandbox: its working directory and environment variables carry across commands and file operations. Create one with `createSession()`. ```typescript const session = await sandbox.createSession({ cwd: "/tmp", env: { GREETING: "hello from a session" }, }); await session.exec("echo started > log.txt"); const greeting = await session.exec("echo $GREETING"); const log = await session.exec("cat /tmp/log.txt"); console.log(session.sessionId); console.log(greeting.stdout.trim()); console.log(log.stdout.trim()); ``` Sessions expose the same `exec`, `execStream`, `writeFile`, `readFile`, `deleteFile`, `mkdir`, `listFiles`, `gitCheckout`, `setEnvVars`, and the background-process methods (`startProcess`, `getProcess`, …) as the sandbox client. Relative file paths resolve against the session's working directory. Use a session when you need a series of commands to share the same working directory and environment. Use the sandbox client directly for one-off commands. ## AI for Coding Agents The default sandbox image ships a coding agent (and you can install your own). When your project has AI enabled, Ploy injects credentials for the built-in [AI gateway](https://docs.meetploy.com/features/ai) into the sandbox automatically, so an agent running inside it can call models without you managing any API keys. Enable it by adding `ai: true` alongside the sandbox binding: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist ai: true sandbox: SANDBOX: default ``` With `ai: true`, every sandbox launched for the project gets these environment variables set inside the container: | Variable | Purpose | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `PLOY_AI_URL` | Base URL of the Ploy AI gateway. | | `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` | The gateway's `/v1` endpoint, so the Anthropic and OpenAI SDKs route through Ploy. | | `PLOY_AI_TOKEN` | Short-lived token authorizing requests to the gateway. | | `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `OPENAI_API_KEY` | The same token under the names each SDK / agent expects. | Because the agent and the SDKs read these standard variables, an Anthropic- or OpenAI-compatible tool works out of the box — a bare model name is routed through the Ploy gateway with no extra configuration: ```typescript title="src/index.ts" const sandbox = env.SANDBOX.get("agent"); // soulforge (the bundled agent) picks up OPENAI_BASE_URL / OPENAI_API_KEY and // streams its work back. No model credentials are passed in by you. const stream = await sandbox.execStream( `soulforge --headless --model openai/gpt-5.5 "summarize this repository"`, ); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); ``` You can override or add to these variables per command with the `env` option on `exec` / `startProcess`, or for the whole sandbox with `setEnvVars()`. The AI credentials are only injected when the project has `ai: true` — the same gate as the [AI binding](https://docs.meetploy.com/features/ai). Without it, no gateway token is placed in the sandbox. The injected token is scoped to your project and minted with a long TTL so agent sessions that run for several minutes don't expire mid-task. ### Local development Sandbox agents work under `ploy dev` too. Make sure your project has `ai: true` and that you are signed in with `ploy login` — Ploy then injects the same gateway credentials into the local sandbox. Without them the agent has no model access and fails to authenticate (for example `OPENAI_API_KEY is not set`). ## Next Steps * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers * [AI](https://docs.meetploy.com/features/ai) - Use the built-in AI gateway from your workers # Skew Protection URL: https://docs.meetploy.com/features/skew-protection # Skew Protection **Version skew** happens when a user's browser is running the client bundle from one deployment while their follow-up requests hit a newer deployment that went live mid-session. The classic symptom is a `ChunkLoadError` — a code-split JavaScript chunk that no longer exists on the new deployment — along with broken client/server API contracts. Skew Protection fixes this by pinning a client's **framework-managed requests** (static chunks, navigations, prefetches) to the exact deployment that served the initial page load, for as long as that deployment is recent. Full-page navigations still get the latest deployment. ## Enabling Skew Protection is **on by default** for every project. Every successful deployment stays reachable, so pinned requests keep resolving. To opt out, set `skew_protection: false` in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist skew_protection: false # disable skew protection ``` ## Next.js For Next.js projects there is **nothing to configure**. Ploy injects `NEXT_DEPLOYMENT_ID` into the build, which Next.js reads natively to pin its own framework-managed requests — asset loads, client-side navigations, and prefetches all resolve to the deployment that served the page. Next.js also triggers a full reload automatically if it detects a deployment mismatch. Do not set `deploymentId` in your `next.config`. Next.js gives an explicit config value precedence over `NEXT_DEPLOYMENT_ID`, so a custom value would be sent on framework requests instead of the Ploy deployment id and those requests would fail to resolve. Leave it unset and let Ploy manage it. ### Your own client fetches Next.js pins the requests it makes for you, but not the custom `fetch` calls you write. To pin those, use `ployFetch`: ```tsx title="app/profile.tsx" "use client"; import { ployFetch } from "@meetploy/nextjs/config"; const res = await ployFetch("/api/profile"); ``` Full-page navigations (hard refresh, opening a link in a new tab) intentionally load the **latest** deployment. Skew Protection keeps the current page stable; the next full navigation picks up new deployments. ## Other frameworks Skew Protection works with any project — the edge honors a pin on every request. Pinning is only automatic for Next.js; for other frameworks and workers, attach the deployment id yourself. When enabled, these environment variables are available at both build time and runtime: | Variable | Value | | ---------------------- | ---------------------------------------------- | | `PLOY_DEPLOYMENT_ID` | The current deployment's id (always injected). | | `PLOY_SKEW_PROTECTION` | `"1"` when skew protection is enabled. | Send the id on a request using **either** the `?dpl=` query parameter or the `x-deployment-id` header: ```ts const res = await fetch(`/api/data?dpl=${process.env.PLOY_DEPLOYMENT_ID}`); // or const res = await fetch("/api/data", { headers: { "x-deployment-id": process.env.PLOY_DEPLOYMENT_ID }, }); ``` ## How it works 1. **Build** — Ploy injects the deployment id into the build (`NEXT_DEPLOYMENT_ID` for Next.js, plus `PLOY_DEPLOYMENT_ID` and `PLOY_SKEW_PROTECTION` for other frameworks). 2. **Client** — the framework (Next.js automatically) or your code attaches the deployment id to requests as `?dpl=` or `x-deployment-id`. 3. **Edge** — Ploy routes a pinned request to that exact deployment, as long as it is a successful, skew-protected deployment on the same project and branch the host resolved to, created within the max-age window (24 hours by default). Otherwise the request returns `404`, which prompts the framework to reload to the latest version. ## Next Steps * [Configuration](https://docs.meetploy.com/configuration) - Full `ploy.yaml` reference * [Environment Variables](https://docs.meetploy.com/features/env-vars) - Configure worker environment variables * [Next.js](https://docs.meetploy.com/nextjs) - Next.js integration # State URL: https://docs.meetploy.com/features/state # State Ploy State provides a durable key-value store backed by SQLite. The binding type is still `StateBinding`, but it now implements the Cloudflare `KVNamespace` API so existing Workers code can keep using `get`, `put`, `delete`, `getWithMetadata`, and `list` with the same binding names as before. ## Configuration Add a state binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist state: STATE: default ``` The key (`STATE`) is the binding name available in your worker's `env`. The value (`default`) is the state store identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { StateBinding } from "@meetploy/types"; export interface Env { STATE: StateBinding; } ``` `StateBinding` is a KV superset: it supports the Cloudflare KV API plus Ploy-specific `set()` and atomic JSON `update()`. ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/put") { await env.STATE.put("greeting", JSON.stringify({ message: "hello" }), { metadata: { source: "example" }, }); return Response.json({ success: true }); } if (url.pathname === "/get") { const value = await env.STATE.get("greeting", "json"); return Response.json({ value }); } if (url.pathname === "/get-with-metadata") { const result = await env.STATE.getWithMetadata("greeting", { type: "json", }); return Response.json(result); } if (url.pathname === "/delete") { await env.STATE.delete("greeting"); return Response.json({ success: true }); } return new Response("State Worker"); }, } satisfies Ploy; ``` ## API Methods ### get(key) - Get a Value Returns the stored value or `null` if the key doesn't exist. ```typescript const value = await env.STATE.get("my-key"); // value is string | null ``` Cloudflare-style typed reads are also supported: ```typescript const settings = await env.STATE.get("settings", { type: "json", cacheTtl: 60, }); const blob = await env.STATE.get("avatar", "arrayBuffer"); ``` ### get(keys) - Bulk Get Pass an array of keys to receive a `Map`, just like Cloudflare KV. ```typescript const values = await env.STATE.get(["user:1", "user:2"], { type: "json" }); const user1 = values.get("user:1"); ``` ### getWithMetadata(key) - Get Value and Metadata ```typescript const result = await env.STATE.getWithMetadata("settings", { type: "json" }); console.log(result.value); console.log(result.metadata); ``` ### put(key, value) - Store a Value `put()` matches the Cloudflare KV write API. It supports strings, JSON blobs, `ArrayBuffer`, typed arrays, and `ReadableStream`, plus metadata and expiration. ```typescript await env.STATE.put("username", "alice", { expirationTtl: 3600, metadata: { updatedBy: "admin" }, }); ``` ### set(key, value) - Set a Value `set()` remains available for existing Ploy code. It is equivalent to storing a plain string value without KV options. ```typescript await env.STATE.set("username", "alice"); ``` ### delete(key) - Delete a Value Removes a key from the state store. ```typescript await env.STATE.delete("username"); ``` ### list() - List Keys List keys with Cloudflare-compatible `prefix`, `limit`, and `cursor` options. ```typescript const page = await env.STATE.list({ prefix: "user:", limit: 100 }); console.log(page.keys); console.log(page.cursor); ``` ### update(key, update) - Atomic JSON Updates Applies a MongoDB-style update document to a JSON value in a single atomic write. No need to read the full value, modify it in memory, and write it back -- operations are applied directly in SQLite using JSON functions. ```typescript await env.STATE.update("user:123", { $set: { role: "admin", updatedAt: Date.now() }, }); ``` Five operators are supported: | Operator | Description | Example | | -------- | ------------------------------------------------------- | -------------------------------- | | `$set` | Set one or more fields | `{ $set: { name: "Alice" } }` | | `$unset` | Remove one or more fields | `{ $unset: { oldField: "" } }` | | `$inc` | Increment numeric fields | `{ $inc: { visits: 1 } }` | | `$push` | Append a value to an array | `{ $push: { tags: "new-tag" } }` | | `$pop` | Remove first (`-1`) or last (`1`) element from an array | `{ $pop: { queue: -1 } }` | Fields use dot notation for nested paths: `"nested.field"`, `"array.0"`. Use `put()` when you want Cloudflare KV compatibility, metadata, expiration, or binary payloads. Use `update()` when you want atomic partial updates to a JSON document without a read-modify-write cycle. ## Common Patterns ### Storing JSON Data ```typescript await env.STATE.put( "user:123", JSON.stringify({ name: "Alice", role: "admin" }), ); const user = await env.STATE.get("user:123", "json"); ``` ### Feature Flags ```typescript async function isFeatureEnabled( env: PloyEnv, feature: string, ): Promise { const value = await env.STATE.get(`feature:${feature}`); return value === "true"; } // Enable a feature await env.STATE.put("feature:dark-mode", "true"); // Check the feature const enabled = await isFeatureEnabled(env, "dark-mode"); ``` ### Persistent Counters ```typescript // Atomic increment — no read-modify-write needed await env.STATE.update("counters", { $inc: { pageViews: 1 }, }); ``` ### User Preferences ```typescript async function getUserPreferences(env: PloyEnv, userId: string) { const raw = await env.STATE.get(`prefs:${userId}`); return raw ? JSON.parse(raw) : { theme: "light", language: "en" }; } async function setUserPreferences( env: PloyEnv, userId: string, prefs: Record, ) { await env.STATE.put(`prefs:${userId}`, JSON.stringify(prefs)); } ``` ### Atomic Partial Updates When you store JSON objects and only need to change a few fields, use `update()` instead of reading the whole object, modifying it, and writing it back: ```typescript // Instead of this (read-modify-write): const raw = await env.STATE.get("user:123"); const user = JSON.parse(raw!); user.lastSeen = Date.now(); user.visits += 1; await env.STATE.put("user:123", JSON.stringify(user)); // Do this (atomic update): await env.STATE.update("user:123", { $set: { lastSeen: Date.now() }, }); ``` This avoids race conditions when multiple requests update the same key concurrently, and is more efficient since only the changed fields are sent over the wire. #### Counters with $inc ```typescript // Increment a counter atomically (no read-modify-write needed) await env.STATE.update("user:123", { $inc: { visits: 1, score: 10 }, }); ``` #### Queue Management with update() ```typescript // Append to a list await env.STATE.update("job-queue", { $push: { pending: { id: "job_1", task: "send-email" } }, }); // Remove the first item from a list await env.STATE.update("job-queue", { $pop: { pending: -1 }, }); // Multiple operations in one atomic call await env.STATE.update("job-queue", { $pop: { pending: -1 }, $push: { completed: { id: "job_1", finishedAt: Date.now() } }, }); ``` ## Multiple State Bindings You can configure multiple state stores for different use cases: ```yaml title="ploy.yaml" state: STATE: default USER_STATE: users ``` ```typescript // General state await env.STATE.put("config", value); // User-specific state await env.USER_STATE.put("user:abc", userData); ``` ## Next Steps * [Databases](https://docs.meetploy.com/features/db) - Add persistent SQLite databases * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Timers URL: https://docs.meetploy.com/features/timers # Timers Ploy Timers let you schedule durable timers that call your worker's `timer` handler at a specified time. Timers survive restarts and are guaranteed to fire at least once. They can be one-shot or recurring with a fixed interval. They're ideal for delayed actions, reminders, subscription renewals, periodic cleanup, and any task that needs to run at a specific time or on a schedule. ## Configuration Add a timer binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist timer: TIMER: default ``` The key (`TIMER`) is the binding name available in your worker's `env`. The value (`default`) is the timer store identifier. Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { TimerBinding } from "@meetploy/types"; export interface Env { TIMER: TimerBinding; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/schedule") { // Schedule a timer to fire in 60 seconds await env.TIMER.set("reminder-123", Date.now() + 60_000, { userId: "user_abc", message: "Your trial is expiring!", }); return Response.json({ scheduled: true }); } return new Response("Timer Worker"); }, // Called when a timer fires async timer(event, env) { console.log("Timer fired:", event.id); console.log("Payload:", event.payload); }, } satisfies Ploy; ``` ## Timer Binding API ### set(id, scheduledTime, options?) - Schedule a Timer Schedules a new timer on this binding. If a timer with the same ID already exists on the same timer binding, it is replaced. The third argument can be either a plain payload value (for simple one-shot timers) or an options object with `payload` and `intervalMs` fields. ```typescript // Simple one-shot timer with a payload await env.TIMER.set("order-followup", new Date("2025-12-01T09:00:00Z"), { orderId: "order_123", }); // One-shot timer without payload await env.TIMER.set("reminder", Date.now() + 3600_000); // Recurring timer using options object await env.TIMER.set("cleanup", Date.now() + 60_000, { payload: { type: "cache" }, intervalMs: 5 * 60_000, // Every 5 minutes }); ``` * `id` - A unique identifier for the timer. Use this to update or cancel it later. * `scheduledTime` - A `Date` object or Unix timestamp in milliseconds. * `options` - Either a plain JSON-serializable payload, or an options object: * `payload` - Optional JSON-serializable data delivered with the timer when it fires. * `intervalMs` - Optional interval in milliseconds. When set, the timer automatically reschedules after each fire. ### get(id) - Get Timer Details Returns the timer details, or `null` if the timer doesn't exist or has already fired. ```typescript const timer = await env.TIMER.get("order-followup"); if (timer) { console.log(timer.id); // "order-followup" console.log(timer.scheduledTime); // Unix timestamp (ms) console.log(timer.payload); // { orderId: "order_123" } console.log(timer.intervalMs); // undefined for one-shot, number for recurring } ``` ### delete(id) - Cancel a Timer Cancels a scheduled timer so it won't fire. For recurring timers, this stops all future executions. ```typescript await env.TIMER.delete("order-followup"); ``` ## Timer Handler The `timer` export on your worker is called when a timer fires: ```typescript async timer(event, env, ctx) { console.log({ id: event.id, // The timer identifier scheduledTime: event.scheduledTime, // When it was scheduled to fire (ms) payload: event.payload, // The payload you provided intervalMs: event.intervalMs, // Interval in ms (if recurring) }); } ``` Timers that throw an error are automatically retried after a short backoff. Make your timer handler idempotent to handle duplicate deliveries. ## Recurring Timers Set `intervalMs` to create a recurring timer that automatically reschedules after each fire. The next fire time is calculated as `previousFireTime + intervalMs`, ensuring consistent intervals regardless of handler execution time. ### Every 5 Minutes ```typescript await env.TIMER.set("metrics-report", Date.now(), { intervalMs: 5 * 60_000, }); ``` ### Every Hour with Payload ```typescript await env.TIMER.set("hourly-sync", Date.now(), { payload: { source: "external-api" }, intervalMs: 60 * 60_000, }); ``` ### Daily Cleanup ```typescript // Start tomorrow at midnight, repeat daily const tomorrow = new Date(); tomorrow.setHours(24, 0, 0, 0); await env.TIMER.set("daily-cleanup", tomorrow, { payload: { retentionDays: 30 }, intervalMs: 24 * 60 * 60_000, }); ``` ### Stop a Recurring Timer Use `delete` to cancel a recurring timer and prevent future executions: ```typescript await env.TIMER.delete("metrics-report"); ``` ## Common Patterns ### Delayed Notifications ```typescript async fetch(request, env) { const { userId, message, delayMinutes } = await request.json(); await env.TIMER.set( `notify-${userId}-${Date.now()}`, Date.now() + delayMinutes * 60_000, { userId, message }, ); return Response.json({ scheduled: true }); } ``` ### Subscription Renewal Reminders ```typescript // When a user subscribes, schedule a reminder before renewal await env.TIMER.set( `renewal-${userId}`, renewalDate.getTime() - 3 * 24 * 3600_000, // 3 days before { userId, plan: "pro" }, ); // If the user cancels, remove the reminder await env.TIMER.delete(`renewal-${userId}`); ``` ### Rescheduling Timers Since `set` replaces existing timers with the same ID, you can reschedule by calling `set` again: ```typescript // Reschedule to a later time await env.TIMER.set("task-deadline", newDeadline.getTime(), { taskId: "123" }); ``` ## Multiple Timer Bindings You can configure multiple timer stores for different use cases: ```yaml title="ploy.yaml" timer: TIMER: default NOTIFICATIONS: notifications ``` ```typescript // General-purpose timers await env.TIMER.set("cleanup", Date.now() + 3600_000); // Notification-specific timers await env.NOTIFICATIONS.set("reminder", Date.now() + 60_000, { channel: "email", }); ``` ## Next Steps * [Queues](https://docs.meetploy.com/features/queues) - Send messages for async processing * [Workflows](https://docs.meetploy.com/features/workflows) - Orchestrate multi-step processes * [State](https://docs.meetploy.com/features/state) - Add durable key-value storage # Workers URL: https://docs.meetploy.com/features/workers # Workers Ploy supports deploying serverless workers using [Cloudflare's workerd runtime](https://github.com/cloudflare/workerd). Workers are lightweight, fast, and scale automatically to handle incoming requests. If you're migrating an existing Cloudflare Worker, Ploy is designed to let you keep the same D1 and KV calling patterns. Match the binding names in `ploy.yaml`, run `ploy types`, and most worker code can move over unchanged. ## What are Workers? Workers are JavaScript/TypeScript functions that run in response to HTTP requests. They execute in a V8 isolate environment, providing: * **Fast cold starts** - Workers start in milliseconds * **Global distribution** - Run close to your users * **Auto-scaling** - Handle any amount of traffic * **Cost-effective** - Scale to zero when not in use ## Basic Worker Example Here's the simplest worker that responds to all requests: ```typescript title="src/index.ts" export default { fetch() { return new Response("hi!"); }, }; ``` This worker exports a default object with a `fetch` method that returns a plain text response. ## Project Structure A basic worker project requires: ``` my-worker/ ├── src/ │ └── index.ts # Worker entry point ├── package.json # Project configuration └── tsconfig.json # TypeScript configuration ``` ### package.json ```json title="package.json" { "name": "my-worker", "version": "0.0.0", "private": true, "type": "module", "main": "src/index.ts", "scripts": { "build": "ploy build" }, "dependencies": { "@meetploy/types": "latest" }, "devDependencies": { "@meetploy/cli": "latest", "typescript": "5.9.2" } } ``` The `"type": "module"` field is required for ES modules support. The `main` field should point to your entry file. ## Request and Response ### Handling Requests The `fetch` method receives a `Request` object with information about the incoming request: Ploy infers the full `fetch()` entrypoint signature automatically, so the docs omit explicit `Request`, `PloyEnv`, and `ExecutionContext` annotations on worker handlers. ```typescript title="src/index.ts" export default { async fetch(request) { const url = new URL(request.url); const path = url.pathname; if (path === "/") { return new Response("Home page"); } if (path === "/about") { return new Response("About page"); } return new Response("Not Found", { status: 404 }); }, }; ``` ### Response Types You can return different types of responses: ```typescript // Plain text new Response("Hello World"); // JSON new Response(JSON.stringify({ message: "Hello" }), { headers: { "Content-Type": "application/json" }, }); // HTML new Response("

Hello World

", { headers: { "Content-Type": "text/html" }, }); // With status code new Response("Not Found", { status: 404 }); // With headers new Response("OK", { headers: { "Content-Type": "text/plain", "Cache-Control": "max-age=3600", }, }); ``` ## Background Tasks with `ctx.waitUntil()` Workers on Ploy support `ctx.waitUntil()` with the same execution model as Cloudflare Workers. Ploy uses `workerd`'s native execution context, so you can return a response immediately while background work continues until the promise settles. Use this for non-blocking work such as analytics, cache warming, or webhook delivery: ```typescript title="src/index.ts" export default { async fetch(request, _env, ctx) { const url = new URL(request.url); if (url.pathname === "/signup") { ctx.waitUntil( fetch("https://example.com/webhooks/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: url.searchParams.get("email"), }), }), ); return Response.json({ queued: true }); } return new Response("ok"); }, }; ``` The client gets the response right away, but the promise passed to `ctx.waitUntil()` keeps running in the background. ## Environment Variables Workers can access environment variables for configuration: ```typescript title="src/index.ts" export default { async fetch(request, env) { const apiKey = process.env.API_KEY; // env.vars carries the same values, if you prefer the handler's // env argument over a global. const region = env.vars.API_REGION; await fetch(`https://${region}.api.example.com/data`, { headers: { Authorization: `Bearer ${apiKey}`, }, }); return new Response("OK"); }, } satisfies Ploy; ``` Set environment variables in your Ploy project settings, or in a `.env` file for local development. They are injected at runtime with nothing to declare. See [Environment Variables](https://docs.meetploy.com/features/env-vars) for the details. ## Cloudflare Compatibility Ploy workers run on `workerd`, and Ploy's storage bindings now follow the Cloudflare shapes for the most common migration targets: * `db` bindings generate `Database` types, which stay compatible with the Cloudflare `D1Database` API and support `prepare`, `batch`, `exec`, and `withSession`. * `state` bindings keep the same Ploy names but expose the Cloudflare KV API (`get`, `put`, `delete`, `getWithMetadata`, `list`) in addition to Ploy's `set()` and `update()`. That means code like this generally ports directly: ```typescript title="src/index.ts" export default { async fetch(_request, env) { await env.DB.exec( "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)", ); await env.STATE.put("settings", JSON.stringify({ theme: "dark" })); const settings = await env.STATE.get("settings", "json"); const user = await env.DB.prepare("SELECT 1 AS id, 'Ada' AS name").first<{ id: number; name: string; }>(); return Response.json({ user, settings }); }, }; ``` ## Routing Create a simple router by parsing the URL pathname: ```typescript title="src/index.ts" export default { async fetch(request) { const url = new URL(request.url); // Health check endpoint if (url.pathname === "/health") { return new Response("ok"); } // API endpoint if (url.pathname === "/api/users") { return new Response( JSON.stringify({ users: [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, ], }), { headers: { "Content-Type": "application/json" }, }, ); } // Default response return new Response("Welcome!"); }, }; ``` ## Query Parameters Access URL query parameters: ```typescript export default { async fetch(request) { const url = new URL(request.url); // Get query parameters const name = url.searchParams.get("name") || "Guest"; const age = url.searchParams.get("age"); return new Response(`Hello ${name}${age ? `, age ${age}` : ""}!`); }, }; ``` Example request: `/greet?name=Alice&age=25` ## HTTP Methods Handle different HTTP methods: ```typescript export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === "/api/data") { switch (request.method) { case "GET": return new Response(JSON.stringify({ data: "..." }), { headers: { "Content-Type": "application/json" }, }); case "POST": const body = await request.json(); return new Response(JSON.stringify({ success: true, body }), { headers: { "Content-Type": "application/json" }, }); case "DELETE": return new Response(null, { status: 204 }); default: return new Response("Method Not Allowed", { status: 405 }); } } return new Response("Not Found", { status: 404 }); }, }; ``` ## Request Body Parse different request body types: ```typescript export default { async fetch(request) { if (request.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } // Parse JSON if (request.headers.get("Content-Type")?.includes("application/json")) { const json = await request.json(); return new Response(JSON.stringify({ received: json }), { headers: { "Content-Type": "application/json" }, }); } // Parse form data if ( request.headers .get("Content-Type") ?.includes("application/x-www-form-urlencoded") ) { const formData = await request.formData(); const name = formData.get("name"); return new Response(`Hello ${name}!`); } // Plain text const text = await request.text(); return new Response(`Received: ${text}`); }, }; ``` ## Error Handling Add error handling to your workers: ```typescript export default { async fetch(request) { try { const url = new URL(request.url); if (url.pathname === "/error") { throw new Error("Something went wrong!"); } return new Response("Success"); } catch (error) { console.error("Worker error:", error); return new Response( JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error", }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } }, }; ``` ## TypeScript Support Add TypeScript types for better development experience: ```typescript title="src/index.ts" interface Env { API_KEY: string; DATABASE_URL: string; } interface ApiResponse { success: boolean; data?: unknown; error?: string; } export default { async fetch(request, env) { const response: ApiResponse = { success: true, data: { message: "Hello from TypeScript!" }, }; return new Response(JSON.stringify(response), { headers: { "Content-Type": "application/json" }, }); }, }; ``` ## Deployment To deploy your worker to Ploy: 1. Push your code to a GitHub repository 2. Connect the repository in Ploy dashboard 3. Configure project type as **Dynamic** 4. Set environment variables if needed 5. Push code to trigger deployment Workers are automatically built and deployed when you push to your repository. Build logs are available in real-time. ## Best Practices * **Keep workers lightweight** - Workers should respond quickly * **Use async/await** - Handle asynchronous operations properly * **Set appropriate headers** - Include `Content-Type` and caching headers * **Handle errors gracefully** - Always wrap code in try/catch blocks * **Log important events** - Use `console.log()` for debugging (visible in deployment logs) * **Validate input** - Check query parameters and request bodies * **Return proper status codes** - Use 200 for success, 404 for not found, 500 for errors ## Next Steps * [AI Examples](https://docs.meetploy.com/features/ai) - Learn how to integrate AI models in your workers * [Configuration](https://docs.meetploy.com/configuration) - Configure your Ploy project * [Self-Host](https://docs.meetploy.com/self-host) - Deploy Ploy on your own infrastructure # Workflows URL: https://docs.meetploy.com/features/workflows # Workflows Ploy Workflows let you define long-running, multi-step processes that survive failures. Each step is durably persisted and automatically retried on errors. ## Configuration Add a workflow binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist workflow: ORDER_FLOW: order_processing ``` The key (`ORDER_FLOW`) is the binding name. The value (`order_processing`) must match the workflow function name in your code. ### Configuring Retries By default, each step is retried up to **3 times** on failure with exponential backoff. You can customize the default retry count per workflow in `ploy.yaml`: ```yaml title="ploy.yaml" workflow: ORDER_FLOW: name: order_processing retries: 5 # Retry failed steps up to 5 times FAST_FLOW: name: quick_task retries: 0 # No retries — fail immediately on error ``` You can also override retries per step using step options: ```typescript const result = await step.run( "call-external-api", async () => { return await callAPI(); }, { retries: 10 }, // Override: retry this step up to 10 times ); ``` Run `ploy types` to generate TypeScript types: ```typescript title="env.d.ts" import type { WorkflowBinding } from "@meetploy/types"; export interface Env { ORDER_FLOW: WorkflowBinding; } ``` ## Basic Example ```typescript title="src/index.ts" export default { async fetch(request, env) { // Trigger a workflow execution const { executionId } = await env.ORDER_FLOW.trigger({ orderId: "123", amount: 99.99, }); return Response.json({ executionId }); }, workflows: { order_processing: { handler: async ({ input, step, }: WorkflowContext) => { // Step 1: Validate const order = await step.run("validate", async () => { return { valid: true, orderId: input.orderId }; }); // Step 2: Charge payment const payment = await step.run("charge", async () => { return { paymentId: `pay_${Date.now()}` }; }); // Step 3: Fulfill const fulfillment = await step.run("fulfill", async () => { return { trackingNumber: `TRACK_${Date.now()}` }; }); return { orderId: order.orderId, paymentId: payment.paymentId }; }, }, }, } satisfies Ploy; ``` ## Inputs and Outputs Each workflow execution stores two layers of data: * **Execution input**: the object you pass to `trigger()` * **Execution output**: the final value returned by the workflow handler Each step can also record its own snapshot data: * **Step input**: optional metadata you pass through `step.run(..., { input })` * **Step output**: the value returned from the step callback ```typescript const validation = await step.run( "validate", async () => { return { valid: true, orderId: input.orderId, amountCents: Math.round(input.amount * 100), }; }, { input: { orderId: input.orderId, amount: input.amount, }, }, ); return { orderId: validation.orderId, amountCents: validation.amountCents, }; ``` Use `input` in the step options when you want the dashboard to show the exact payload that reached a specific step. The step return value becomes the step output snapshot. ## Workflow Binding API ### Trigger Start a new workflow execution: ```typescript const { executionId } = await env.ORDER_FLOW.trigger({ orderId: "123" }); ``` ### Get Status Check execution status: ```typescript const execution = await env.ORDER_FLOW.getExecution(executionId); // { status: "running" | "completed" | "failed", result?: any } ``` ### Cancel Cancel a running execution: ```typescript await env.ORDER_FLOW.cancel(executionId); ``` ## Step API ### step.run Execute a named step. Results are persisted, so re-runs skip completed steps: ```typescript const result = await step.run("step-name", async () => { return { data: "value" }; }); ``` ### step.sleep Pause execution for a duration (in milliseconds): ```typescript await step.sleep(5000); // Wait 5 seconds ``` Steps that throw are automatically retried up to 3 times by default (configurable in `ploy.yaml` or per-step via options). Use unique step names to ensure idempotency. ## Error Handling Each workflow definition supports an optional `onError` callback that runs when the handler throws. Use it for cleanup, alerting, or releasing locks: ```typescript workflows: { order_processing: { handler: async ({ input, step }) => { // ... workflow steps }, onError: async (error, { env }) => { console.error("Workflow failed:", error.message); // Release locks, send alerts, clean up resources }, }, }, ``` The `onError` hook runs **before** the workflow is marked as failed. If `onError` itself throws, the error is ignored and the workflow still fails normally. ## Workflow UI The local dashboard lets you inspect both execution-level state and per-step progress after you trigger a workflow. [Screenshot: Workflow executions list in the local Ploy dashboard] The execution detail view is where execution input/output, step input/output, retry attempts, and timing become visible. This example was triggered from `examples/workflow-simple` with a custom order payload. That example now exposes both `/trigger` for a successful execution and `/trigger-failing` for a workflow that fails the `charge_payment` step after all retries, which is useful for inspecting the final failure state and retry history in the UI. [Screenshot: Workflow execution detail view showing execution and step inputs and outputs] ## Next Steps * [Queues](https://docs.meetploy.com/features/queues) - Send messages for async processing * [Workers](https://docs.meetploy.com/features/workers) - Learn about Ploy workers # Database URL: https://docs.meetploy.com/nextjs/database # Database Add SQLite databases to your Next.js application with Ploy. Generated bindings work directly in `getPloyContext()`, so existing SQL access patterns transfer cleanly. ## Configuration Add a database binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist db: DB: default ``` Generate types: ```bash pnpm ploy types ``` ## Migrations Create SQL migrations in `migrations/`. Ploy runs them in filename order and tracks applied files in `ploy_internal_db_migrations` inside the same database. For a single DB binding: ```text title="migrations/" migrations/ └── 20260403171410_init/ ├── migration.sql └── snapshot.json ``` Only `.sql` files are executed, so sibling files such as `snapshot.json` are ignored. If your project uses multiple DB bindings, scope migrations by binding name: ```text title="migrations/" migrations/ ├── DB/ │ └── 001_create_users.sql └── ANALYTICS_DB/ └── 20260403171410_init/ └── migration.sql ``` Production deploys on the default branch apply pending migrations before upload and log them in the build output. During local development, `ploy dev` applies project migrations on startup. ## API Route Example This example assumes your `users` table already exists from a migration in `migrations/`. ```typescript title="app/api/users/route.ts" import { NextResponse } from "next/server"; import { getPloyContext } from "@meetploy/nextjs"; interface User { id: number; name: string; email: string; } export async function GET() { const { env } = getPloyContext(); const result = await env.DB.prepare("SELECT * FROM users").all(); return NextResponse.json({ users: result.results }); } export async function POST(request: Request) { const { env } = getPloyContext(); const { name, email } = await request.json(); const result = await env.DB.prepare( "INSERT INTO users (name, email) VALUES (?, ?)", ) .bind(name, email) .run(); return NextResponse.json({ success: true, meta: result.meta }); } ``` ## Server Component Example ```typescript title="app/users/page.tsx" import { getPloyContext } from "@meetploy/nextjs"; interface User { id: number; name: string; } export default async function UsersPage() { const { env } = getPloyContext(); const { results: users } = await env.DB.prepare( "SELECT * FROM users" ).all(); return (
    {users.map((user) => (
  • {user.name}
  • ))}
); } ``` ## Query Methods ### all() - Get All Rows ```typescript const { results } = await env.DB.prepare("SELECT * FROM users").all(); ``` ### first() - Get First Row ```typescript const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?") .bind(1) .first(); ``` ### run() - Execute Statement ```typescript const result = await env.DB.prepare("INSERT INTO users (name) VALUES (?)") .bind("Alice") .run(); ``` ### exec() - Run Raw SQL ```typescript const result = await env.DB.exec(` CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); PRAGMA optimize; `); console.log(result.count); ``` ### withSession() - Session Compatibility ```typescript const session = env.DB.withSession("first-primary"); const user = await session .prepare("SELECT * FROM users WHERE id = ?") .bind(1) .first(); ``` Always use prepared statements with `.bind()` to prevent SQL injection. ## Local Development During development, the local Ploy dashboard at `http://localhost:4000` lets you: * Browse table contents * Execute SQL queries * Inspect database structure Project migrations are applied on startup, and data persists across restarts in the `.ploy` directory. ## Next Steps * [Queues](https://docs.meetploy.com/nextjs/queues) - Process background jobs * [Workflows](https://docs.meetploy.com/nextjs/workflows) - Build durable multi-step processes # Next.js URL: https://docs.meetploy.com/nextjs # Next.js Ploy provides first-class support for Next.js applications with access to Cloudflare-compatible D1 and KV bindings, plus queues and workflows, through a simple integration package. ## Installation Install the Ploy CLI and Next.js integration: ```bash pnpm add @meetploy/cli @meetploy/nextjs ``` Add a dev script to your `package.json`: ```json title="package.json" { "scripts": { "dev": "ploy dev" } } ``` ## Configuration Create a `ploy.yaml` file in your project root: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist ``` Update your `next.config.ts` to initialize Ploy bindings in development: ```typescript title="next.config.ts" import type { NextConfig } from "next"; import { initPloyForDev } from "@meetploy/nextjs"; if (process.env.NODE_ENV === "development") { await initPloyForDev(); } const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig; ``` The `initPloyForDev()` function reads your `ploy.yaml` and sets up mock bindings that proxy to the local Ploy emulator. ## Usage Use `getPloyContext()` to access Ploy bindings in Server Components or API routes: ```typescript title="app/api/example/route.ts" import { NextResponse } from "next/server"; import { getPloyContext } from "@meetploy/nextjs"; export async function GET() { const { env } = getPloyContext(); // Access your bindings through env const result = await env.DB.prepare("SELECT * FROM users").all(); return NextResponse.json({ users: result.results }); } ``` ## Local Development Run your Next.js app with Ploy: ```bash pnpm dev ``` This starts: * Your Next.js app * The Ploy emulator for databases, queues, and workflows * A local dashboard at `http://localhost:4000` for insights The local dashboard lets you inspect database contents, view queue messages, and monitor workflow executions. ## Environment Variables Read them with `process.env`, exactly as in any Next.js app: ```typescript title="app/page.tsx" export default function Page() { return

{process.env.APP_NAME}

; } ``` Values come from a `.env` file locally and from the Ploy dashboard in production. There is nothing to declare — `ploy.yaml` has no `env` section — and `getPloyContext()` is only needed for bindings such as databases and queues. The same values are on `env.vars` if you prefer reading them from the context rather than a global. See [Environment Variables](https://docs.meetploy.com/features/env-vars) for the full details. ## Type Safety Generate TypeScript types from your `ploy.yaml`: ```bash pnpm ploy types ``` This creates an `env.d.ts` file with type-safe bindings: ```typescript title="env.d.ts" import type { Database, StateBinding } from "@meetploy/types"; declare module "@meetploy/nextjs" { interface PloyEnv { DB: Database; STATE: StateBinding; } } ``` Now `getPloyContext()` returns properly typed bindings: ```typescript const { env } = getPloyContext(); // env.DB is typed as Database // env.STATE is typed as StateBinding (Cloudflare KV-compatible) ``` ## Next Steps * [Database](https://docs.meetploy.com/nextjs/database) - Add SQLite databases to your app * [Queues](https://docs.meetploy.com/nextjs/queues) - Process background jobs * [Workflows](https://docs.meetploy.com/nextjs/workflows) - Build durable multi-step processes * [Environment Variables](https://docs.meetploy.com/features/env-vars) - Configure your app with `process.env` # Queues URL: https://docs.meetploy.com/nextjs/queues # Queues Add message queues to your Next.js application for background job processing. ## Configuration Add a queue binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist queue: TASKS: tasks ``` Generate types: ```bash pnpm ploy types ``` ## Sending Messages Send messages to the queue from API routes or Server Actions: ```typescript title="app/api/queue/route.ts" import { NextResponse } from "next/server"; import { getPloyContext } from "@meetploy/nextjs"; export async function POST(request: Request) { const { env } = getPloyContext(); const body = await request.json(); const { messageId } = await env.TASKS.send({ action: body.action, data: body.data, }); return NextResponse.json({ success: true, messageId }); } ``` ### Send with Delay ```typescript const { messageId } = await env.TASKS.send( { action: "send-reminder" }, { delaySeconds: 3600 }, // 1 hour delay ); ``` ### Batch Send ```typescript const { messageIds } = await env.TASKS.sendBatch([ { payload: { action: "task-1" } }, { payload: { action: "task-2" } }, { payload: { action: "task-3" } }, ]); ``` ## Message Handler Create a `ploy.ts` file in your app directory to handle incoming messages: ```typescript title="app/ploy.ts" export default { async message(event, env, ctx) { console.log("Processing message:", { id: event.id, queue: event.queueName, payload: event.payload, attempt: event.attempt, }); const payload = event.payload as { action: string; data?: unknown }; switch (payload.action) { case "send-email": // Process email sending break; case "generate-report": // Generate report break; default: console.log("Unknown action:", payload.action); } }, } satisfies Ploy; ``` Messages that throw an error are automatically retried with exponential backoff. ## Complete Example Here's a complete example with database integration: ```typescript title="app/ploy.ts" export default { async message(event, env, ctx) { const payload = event.payload as { action: string; data: unknown }; // Store processed message in database await env.DB.prepare( "INSERT INTO processed_messages (message_id, payload, processed_at) VALUES (?, ?, ?)", ) .bind(event.id, JSON.stringify(payload), new Date().toISOString()) .run(); console.log("Message processed:", event.id); }, } satisfies Ploy; ``` ## Local Development During development, the local Ploy dashboard at `http://localhost:4000` lets you: * View pending and processed messages * Manually retry failed messages * Inspect message payloads ## Next Steps * [Database](https://docs.meetploy.com/nextjs/database) - Store data with SQLite * [Workflows](https://docs.meetploy.com/nextjs/workflows) - Build durable multi-step processes # Workflows URL: https://docs.meetploy.com/nextjs/workflows # Workflows Build long-running, multi-step processes that survive failures with automatic retries and state persistence. ## Configuration Add a workflow binding in your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist workflow: DATA_FLOW: data_processing ``` The key (`DATA_FLOW`) is the binding name. The value (`data_processing`) must match the workflow function name in your code. You can use the expanded form to configure retries per workflow: ```yaml title="ploy.yaml" workflow: DATA_FLOW: name: data_processing retries: 5 # Retry failed steps up to 5 times (default: 3) ``` Generate types: ```bash pnpm ploy types ``` ## Triggering Workflows Trigger workflows from API routes or Server Actions: ```typescript title="app/api/workflow/route.ts" import { NextResponse } from "next/server"; import { getPloyContext } from "@meetploy/nextjs"; export async function POST(request: Request) { const { env } = getPloyContext(); const body = await request.json(); const { executionId } = await env.DATA_FLOW.trigger({ action: body.action, value: body.value, }); return NextResponse.json({ success: true, executionId }); } ``` ### Get Execution Status ```typescript export async function GET(request: Request) { const { env } = getPloyContext(); const url = new URL(request.url); const executionId = url.searchParams.get("executionId"); const execution = await env.DATA_FLOW.getExecution(executionId); return NextResponse.json({ id: execution.id, status: execution.status, // "running" | "completed" | "failed" output: execution.output, }); } ``` ### Cancel Execution ```typescript await env.DATA_FLOW.cancel(executionId); ``` ## Workflow Definition Define workflows in your `ploy.ts` file: ```typescript title="app/ploy.ts" interface WorkflowInput { action: string; value: number; } interface WorkflowOutput { action: string; originalValue: number; processedValue: number; } export default { workflows: { data_processing: { handler: async ({ input, env, step, }: WorkflowContext): Promise => { // Step 1: Validate input const validation = await step.run("validate", async () => { if (!input.action) { throw new Error("Missing action"); } return { valid: true, action: input.action }; }); // Step 2: Process the value const processed = await step.run("process", async () => { let result: number; switch (input.action) { case "double": result = input.value * 2; break; case "square": result = input.value * input.value; break; default: result = input.value; } return { processedValue: result }; }); // Step 3: Store result await step.run("store", async () => { await env.DB.prepare( "INSERT INTO results (action, input, output) VALUES (?, ?, ?)", ) .bind(input.action, input.value, processed.processedValue) .run(); }); return { action: input.action, originalValue: input.value, processedValue: processed.processedValue, }; }, }, }, } satisfies Ploy; ``` ## Inputs and Outputs There are two levels of persisted workflow data: * **Execution input**: the payload passed to `env.DATA_FLOW.trigger(...)` * **Execution output**: the final value returned by the workflow handler For deeper debugging, each step can also persist its own snapshots: * **Step input**: optional metadata passed with `step.run(..., { input })` * **Step output**: the value returned from the step callback ```typescript const processed = await step.run( "process", async () => { return { processedValue: input.value * 2 }; }, { input: { action: input.action, value: input.value, }, }, ); ``` This is especially useful when a workflow transforms data between steps and you want the dashboard to show what each step actually received. ## Step API ### step.run Execute a named step. Results are persisted, so re-runs skip completed steps: ```typescript const result = await step.run("step-name", async () => { return { data: "value" }; }); ``` ### step.sleep Pause execution for a duration (in milliseconds): ```typescript await step.sleep(5000); // Wait 5 seconds ``` Steps that throw are automatically retried up to 3 times by default (configurable in `ploy.yaml` or per-step via options). Use unique step names to ensure idempotency. ## Error Handling Each workflow definition supports an optional `onError` callback that runs when the handler throws. Use it for cleanup, alerting, or releasing locks: ```typescript workflows: { data_processing: { handler: async ({ input, env, step }) => { // ... workflow steps }, onError: async (error, { env }) => { console.error("Workflow failed:", error.message); // Release locks, send alerts, clean up resources }, }, }, ``` The `onError` hook runs **before** the workflow is marked as failed. If `onError` itself throws, the error is ignored and the workflow still fails normally. ## Local Development During development, the local Ploy dashboard at `http://localhost:4000` lets you: * View workflow executions * Inspect step results * Monitor execution status [Screenshot: Workflow executions list in the local Ploy dashboard] Open an execution to inspect execution input/output, step-level input/output, retry attempts, and timing details. [Screenshot: Workflow execution detail view showing execution and step inputs and outputs] ## Next Steps * [Database](https://docs.meetploy.com/nextjs/database) - Store data with SQLite * [Queues](https://docs.meetploy.com/nextjs/queues) - Process background jobs # Authentication URL: https://docs.meetploy.com/start/auth # Authentication This guide shows how to integrate Ploy Auth into a Next.js application using the `@meetploy/auth-react` package. ## Setup ### 1. Install Dependencies ```bash pnpm add @meetploy/auth-react ``` ### 2. Configure Auth Binding Add the auth configuration to your `ploy.yaml`: ```yaml title="ploy.yaml" kind: dynamic build: pnpm build out: dist auth: binding: AUTH_DB ``` ### 3. Add AuthProvider Wrap your application with the `AuthProvider`: ```tsx title="app/layout.tsx" import { AuthProvider } from "@meetploy/auth-react"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ``` ## Pre-Built Components ### SignInForm A complete sign-in form with email and password fields: ```tsx title="app/login/page.tsx" "use client"; import { SignInForm } from "@meetploy/auth-react"; import { useRouter } from "next/navigation"; export default function LoginPage() { const router = useRouter(); return (

Sign In

router.push("/dashboard")} onError={(error) => console.error(error.message)} />

Don't have an account?{" "} Sign up

); } ``` ### SignUpForm A complete sign-up form with email, password, and confirm password fields: ```tsx title="app/signup/page.tsx" "use client"; import { SignUpForm } from "@meetploy/auth-react"; import { useRouter } from "next/navigation"; export default function SignUpPage() { const router = useRouter(); return (

Create Account

router.push("/dashboard")} onError={(error) => console.error(error.message)} />

Already have an account?{" "} Sign in

); } ``` #### Custom Metadata Fields Add extra fields to collect user metadata during signup: ```tsx console.log(user)} /> ``` ### Component Props Both components accept these props: ```typescript interface SignInFormProps { onSuccess?: (user: PloyUser) => void; // Called after successful auth onError?: (error: Error) => void; // Called on error redirectTo?: string; // Auto-redirect URL after success className?: string; // Custom CSS class } interface SignUpFormProps extends SignInFormProps { fields?: string[]; // Additional metadata fields to collect } ``` ## useAuth Hook Access auth state anywhere in your app: ```tsx title="components/user-menu.tsx" "use client"; import { useAuth } from "@meetploy/auth-react"; export function UserMenu() { const { user, isLoading, signOut } = useAuth(); if (isLoading) { return
Loading...
; } if (!user) { return Sign In; } return (
{user.email}
); } ``` ### Hook Return Value ```typescript interface AuthContextValue { user: PloyUser | null; // Current user or null isLoading: boolean; // True during initial load isAuthenticated: boolean; // True if user is signed in accessToken: string | null; // Current access token // Auth methods signIn: (email: string, password: string) => Promise; signUp: ( email: string, password: string, metadata?: Record, ) => Promise; signOut: () => Promise; refreshTokens: () => Promise; } ``` ## Protected Routes ### Client-Side Protection ```tsx title="app/dashboard/page.tsx" "use client"; import { useAuth } from "@meetploy/auth-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; export default function DashboardPage() { const { user, isLoading } = useAuth(); const router = useRouter(); useEffect(() => { if (!isLoading && !user) { router.push("/login"); } }, [user, isLoading, router]); if (isLoading) { return
Loading...
; } if (!user) { return null; } return (

Welcome, {user.email}

{user.metadata?.name &&

Name: {user.metadata.name}

}
); } ``` ### Protected API Routes ```typescript title="app/api/protected/route.ts" export async function GET(request: Request) { const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const token = authHeader.replace("Bearer ", ""); // Verify token using the PLOY_AUTH binding const user = await env.PLOY_AUTH.getUser(token); if (!user) { return Response.json({ error: "Invalid token" }, { status: 401 }); } // User is authenticated return Response.json({ message: "Protected data", userId: user.id, }); } ``` ## Making Authenticated Requests Use the access token from `useAuth` for API calls: ```tsx "use client"; import { useAuth } from "@meetploy/auth-react"; export function DataFetcher() { const { accessToken } = useAuth(); const fetchProtectedData = async () => { const response = await fetch("/api/protected", { headers: { Authorization: `Bearer ${accessToken}`, }, }); return response.json(); }; return ; } ``` ## Styling Components The pre-built components use minimal inline styles. Override with your own CSS: ```tsx ``` ```css title="globals.css" .my-custom-form input { @apply border-2 border-gray-300 rounded-lg px-4 py-2; } .my-custom-form button { @apply bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded; } ``` Or build custom forms using the `useAuth` hook directly: ```tsx "use client"; import { useAuth } from "@meetploy/auth-react"; import { useState } from "react"; export function CustomSignInForm() { const { signIn } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await signIn(email, password); // Handle success } catch (err) { setError(err instanceof Error ? err.message : "Sign in failed"); } }; return
{/* Your custom UI */}
; } ``` ## Token Storage The `AuthProvider` automatically: * Stores tokens in localStorage * Refreshes tokens before expiration * Clears tokens on sign out * Restores session on page reload For enhanced security, consider storing refresh tokens in httpOnly cookies using a custom backend endpoint. ## Error Handling Common error responses from auth endpoints: | Status | Error | Description | | ------ | -------------------------------------- | ------------------------- | | 400 | Invalid email format | Email validation failed | | 400 | Password must be at least 8 characters | Password too short | | 401 | Invalid credentials | Wrong email or password | | 401 | Invalid or expired token | Token verification failed | | 409 | User already exists | Email already registered | Handle errors in your components: ```tsx { if (error.message === "Invalid credentials") { toast.error("Wrong email or password"); } else { toast.error("Something went wrong"); } }} /> ``` ## Full Example See the complete example at [examples/nextjs-auth](https://github.com/meetploy/ploy/tree/main/examples/nextjs-auth). ## Next Steps * [Auth Feature Overview](https://docs.meetploy.com/features/auth) - Learn about auth endpoints and security * [Database](https://docs.meetploy.com/start/database) - Add a database to your app * [Routes](https://docs.meetploy.com/start/routes) - Define type-safe API routes # Database URL: https://docs.meetploy.com/start/database # Database Ploy Start provides built-in Drizzle ORM integration for databases, giving you type-safe database queries with automatic schema inference. ## Setup ### 1. Install Drizzle ```bash pnpm add drizzle-orm ``` ### 2. Configure DB Binding Add a database binding to your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist db: DB: default ``` ### 3. Generate Types Run the Ploy CLI to generate environment types: ```bash pnpm types ``` This creates `env.d.ts` with your DB binding typed. ### 4. Add Migrations Create SQL migrations in `migrations/`. Ploy runs them in filename order and tracks applied files in `ploy_internal_db_migrations` inside the same database. For a single DB binding: ```text title="migrations/" migrations/ └── 20260403171410_init/ ├── migration.sql └── snapshot.json ``` Only `.sql` files are executed, so sibling files such as `snapshot.json` are ignored. For multiple DB bindings, scope migrations by binding name: ```text title="migrations/" migrations/ ├── DB/ │ └── 001_create_users.sql └── ANALYTICS_DB/ └── 20260403171410_init/ └── migration.sql ``` Production deploys on the default branch apply pending migrations before upload and log them in the build output. During local development, `ploy dev` applies project migrations on startup. ## Schema Definition Define your database schema using Drizzle: ```typescript title="src/schema.ts" import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull(), email: text("email").notNull().unique(), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), }); export const posts = sqliteTable("posts", { id: integer("id").primaryKey({ autoIncrement: true }), title: text("title").notNull(), content: text("content"), authorId: integer("author_id").notNull(), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), }); ``` ## Using withDrizzle The `withDrizzle` state factory adds a typed `db` instance to your handler context: ```typescript title="src/index.ts" import { ploy, withDrizzle, z } from "@meetploy/start"; import { eq } from "drizzle-orm"; import * as schema from "./schema.js"; import { users } from "./schema.js"; const worker = ploy() .state(withDrizzle("DB", schema)) .get( "/users", { response: z.object({ users: z.array( z.object({ id: z.number(), name: z.string(), email: z.string(), }), ), }), }, async (ctx) => { // ctx.state.db is typed with your schema const allUsers = await ctx.state.db.select().from(users); return { users: allUsers }; }, ) .build(); export default worker; ``` The `withDrizzle` function takes two arguments: 1. The binding name (must match your `ploy.yaml` db key) 2. Your schema object (optional, but recommended for typed queries) ## Query Examples ### Select All ```typescript const allUsers = await ctx.state.db.select().from(users); ``` ### Select with Where ```typescript import { eq } from "drizzle-orm"; const user = await ctx.state.db .select() .from(users) .where(eq(users.id, 1)) .limit(1); ``` ### Select Specific Columns ```typescript const userNames = await ctx.state.db.select({ name: users.name }).from(users); ``` ### Insert ```typescript await ctx.state.db.insert(users).values({ name: "Alice", email: "alice@example.com", }); ``` ### Update ```typescript await ctx.state.db .update(users) .set({ name: "Alice Smith" }) .where(eq(users.id, 1)); ``` ### Delete ```typescript await ctx.state.db.delete(users).where(eq(users.id, 1)); ``` ## Raw DB Access For operations not supported by Drizzle, access the DB directly via `ctx.env`: ```typescript const result = await ctx.env.DB.exec(` CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); PRAGMA optimize; `); ``` ### Prepared Statements ```typescript const result = await ctx.env.DB.prepare("SELECT * FROM users WHERE id = ?") .bind(userId) .first(); ``` ### Batch Operations ```typescript const results = await ctx.env.DB.batch([ ctx.env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind( "Alice", "alice@example.com", ), ctx.env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind( "Bob", "bob@example.com", ), ]); ``` ## Using withRawDB For raw DB access as state, use `withRawDB`: ```typescript import { ploy, withRawDB } from "@meetploy/start"; const worker = ploy() .state(withRawDB("DB")) .get("/users", {...}, async (ctx) => { const users = await ctx.state.rawDb.query("SELECT * FROM users"); return { users }; }) .build(); ``` ### Raw DB Interface ```typescript interface RawDBHelpers { query(sql: string, ...params: unknown[]): Promise; first(sql: string, ...params: unknown[]): Promise; execute(sql: string, ...params: unknown[]): Promise; batch( statements: { sql: string; params?: unknown[] }[], ): Promise; } ``` ## Full CRUD Example This example assumes your `users` table already exists from a migration in `migrations/`. ```typescript import { ploy, withDrizzle, z } from "@meetploy/start"; import { eq } from "drizzle-orm"; import * as schema from "./schema.js"; import { users } from "./schema.js"; const userSchema = z.object({ id: z.number(), name: z.string(), email: z.string(), }); const worker = ploy() .state(withDrizzle("DB", schema)) // List users .get( "/users", { response: z.object({ users: z.array(userSchema) }), }, async (ctx) => { const allUsers = await ctx.state.db.select().from(users); return { users: allUsers }; }, ) // Get user by ID .get( "/users/:id", { params: z.object({ id: z.string() }), response: z.object({ user: userSchema.nullable() }), }, async (ctx) => { const [user] = await ctx.state.db .select() .from(users) .where(eq(users.id, parseInt(ctx.params.id))) .limit(1); return { user: user ?? null }; }, ) // Create user .post( "/users", { body: z.object({ name: z.string(), email: z.string().email(), }), response: z.object({ user: userSchema }), }, async (ctx) => { const [user] = await ctx.state.db .insert(users) .values(ctx.body) .returning(); return { user }; }, ) // Update user .put( "/users/:id", { params: z.object({ id: z.string() }), body: z.object({ name: z.string().optional(), email: z.string().email().optional(), }), response: z.object({ user: userSchema }), }, async (ctx) => { const [user] = await ctx.state.db .update(users) .set(ctx.body) .where(eq(users.id, parseInt(ctx.params.id))) .returning(); return { user }; }, ) // Delete user .delete( "/users/:id", { params: z.object({ id: z.string() }), response: z.object({ success: z.boolean() }), }, async (ctx) => { await ctx.state.db .delete(users) .where(eq(users.id, parseInt(ctx.params.id))); return { success: true }; }, ) .build(); export default worker; ``` ## Best Practices * **Use migrations** - Keep schema changes in `migrations/` instead of request handlers * **Index frequently queried columns** - Add indexes for better performance * **Validate input** - Use Zod schemas to validate data before inserting * **Handle errors** - Wrap database operations in try/catch * **Use transactions** - Group related operations when needed # Getting Started URL: https://docs.meetploy.com/start # Ploy Start Ploy Start is a type-safe framework for building Cloudflare Workers with automatic type inference, Zod validation, and built-in support for databases, queues, and workflows. ## Features * **Type-Safe Routing** - Full TypeScript inference for params, query, body, and response * **Zod Validation** - Automatic request/response validation with Zod schemas * **Drizzle ORM** - Built-in database integration with Drizzle * **Queue Handlers** - Type-safe message queue processing * **Workflow Handlers** - Durable workflow execution with step functions * **OpenAPI Generation** - Auto-generated API documentation * **Built-in Middleware** - CORS, logging, auth, and rate limiting ## Installation ```bash pnpm add @meetploy/start pnpm add -D drizzle-orm ``` ## Minimal Example The simplest Ploy Start worker: ```typescript title="src/index.ts" import { ploy, z } from "@meetploy/start"; const worker = ploy() .get( "/", { response: z.object({ message: z.string() }), }, async () => { return { message: "Hello from Ploy Start!" }; }, ) .build(); export default worker; ``` That's it! The `ploy()` function creates a builder, you chain route definitions, and `.build()` generates the worker export. ## Project Setup ### Directory Structure ``` my-worker/ ├── src/ │ ├── index.ts # Worker entry point │ └── env.d.ts # Environment types ├── package.json ├── tsconfig.json └── ploy.yaml ``` ### package.json ```json title="package.json" { "name": "my-worker", "version": "0.0.0", "private": true, "type": "module", "main": "src/index.ts", "scripts": { "build": "tsc --noEmit && ploy build", "dev": "ploy dev", "types": "ploy types" }, "dependencies": { "@meetploy/start": "latest", "@meetploy/types": "latest" }, "devDependencies": { "@meetploy/cli": "latest", "typescript": "^5.9.0" } } ``` ### ploy.yaml ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist ``` ### Environment Types Generate environment types with the Ploy CLI: ```bash pnpm types ``` This creates `env.d.ts` with your bindings typed. If you are building with Vite or TanStack Start, use [`ploy vite`](https://docs.meetploy.com/cli/vite) and configure everything through `ploy.yaml`. ## Complete Example Here's a full example with all features: ```typescript title="src/index.ts" import { ploy, withDrizzle, cors, logger, z } from "@meetploy/start"; import * as schema from "./schema.js"; const worker = ploy() // Middleware .use(cors()) .use(logger()) // Database state .state(withDrizzle("DB", schema)) // OpenAPI docs .openapi({ path: "/docs", info: { title: "My API", version: "1.0.0" }, }) // Routes .get( "/", { response: z.object({ message: z.string() }), }, async () => { return { message: "Hello!" }; }, ) .get( "/users", { response: z.object({ users: z.array( z.object({ id: z.number(), name: z.string(), }), ), }), }, async (ctx) => { const users = await ctx.state.db.select().from(schema.users); return { users }; }, ) .build(); export default worker; ``` ## Method Chaining Ploy Start uses method chaining with TypeScript generics to accumulate types. Each method returns a new typed builder: ```typescript const worker = ploy() .state(withDrizzle("DB", schema)) // Adds db to state .use(cors()) // Adds middleware .get("/users", {...}, handler) // Adds GET /users route .post("/users", {...}, handler) // Adds POST /users route .queue("TASKS", {...}, handler) // Adds queue handler .workflow("flow", {...}, handler) // Adds workflow handler .build(); // Returns { fetch, message?, workflows? } ``` The final `.build()` call returns a `PloyHandler` compatible object with: * `fetch` - HTTP request handler * `message` - Queue message handler (if queues defined) * `workflows` - Workflow handlers (if workflows defined) ## What's Next * [Routes](https://docs.meetploy.com/start/routes) - Define HTTP routes with validation * [Database](https://docs.meetploy.com/start/database) - Integrate Drizzle ORM with databases * [Queues](https://docs.meetploy.com/start/queues) - Handle queue messages * [Workflows](https://docs.meetploy.com/start/workflows) - Build durable workflows # Queues URL: https://docs.meetploy.com/start/queues # Queues Ploy Start provides type-safe queue handlers with Zod payload validation for processing background jobs. ## Setup ### 1. Configure Queue Binding Add a queue binding to your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist queue: TASKS: tasks ``` ### 2. Generate Types ```bash pnpm types ``` This creates `env.d.ts` with your queue binding typed as `QueueBinding`. ## Defining Queue Handlers Use `.queue()` to define a type-safe message handler: ```typescript title="src/index.ts" import { ploy, z } from "@meetploy/start"; const worker = ploy() .queue( "TASKS", { payload: z.object({ task: z.string(), data: z.record(z.unknown()).optional(), }), }, async (ctx) => { // ctx.message.payload is typed console.log("Processing:", ctx.message.payload.task); ctx.ack(); }, ) .build(); export default worker; ``` The queue name in `.queue()` must match the binding name in your `ploy.yaml` (e.g., "TASKS"). ## Queue Context The queue handler receives a typed context: ```typescript interface QueueContext { message: { id: string; // Unique message ID queueName: string; // Queue name payload: Payload; // Validated payload attempt: number; // Retry attempt (1-based) timestamp: Date; // When message was sent }; env: Env; // Environment bindings state: State; // State from .state() calls ctx: ExecutionContext; ack: () => void; // Acknowledge success retry: (delayMs?: number) => void; // Retry later deadLetter: (reason?: string) => void; // Send to DLQ } ``` ## Message Handling ### Acknowledge Success Call `ctx.ack()` to mark the message as processed: ```typescript .queue("TASKS", {...}, async (ctx) => { await processTask(ctx.message.payload); ctx.ack(); // Message processed successfully }) ``` ### Retry Later Call `ctx.retry()` to requeue the message: ```typescript .queue("TASKS", {...}, async (ctx) => { try { await processTask(ctx.message.payload); ctx.ack(); } catch (error) { if (ctx.message.attempt < 3) { ctx.retry(5000); // Retry in 5 seconds } else { ctx.deadLetter("Max retries exceeded"); } } }) ``` ### Dead Letter Queue Call `ctx.deadLetter()` to stop processing and log the failure: ```typescript .queue("TASKS", {...}, async (ctx) => { if (!isValidPayload(ctx.message.payload)) { ctx.deadLetter("Invalid payload"); return; } // Process... }) ``` ## Sending Messages Send messages to queues via the binding in your HTTP routes: ### Single Message ```typescript .get("/queue/send", { response: z.object({ messageId: z.string() }) }, async (ctx) => { const { messageId } = await ctx.env.TASKS.send({ task: "process-order", data: { orderId: "123" } }); return { messageId }; }) ``` ### Delayed Message ```typescript .get("/queue/send-delayed", { response: z.object({ messageId: z.string() }) }, async (ctx) => { const { messageId } = await ctx.env.TASKS.send( { task: "reminder" }, { delaySeconds: 60 } // Delay by 60 seconds ); return { messageId }; }) ``` ### Batch Messages ```typescript .get("/queue/batch", { response: z.object({ messageIds: z.array(z.string()) }) }, async (ctx) => { const { messageIds } = await ctx.env.TASKS.sendBatch([ { payload: { task: "job-1" } }, { payload: { task: "job-2" } }, { payload: { task: "job-3" }, delaySeconds: 30 } ]); return { messageIds }; }) ``` ## Multiple Queues Handle multiple queues by calling `.queue()` multiple times: ```typescript const worker = ploy() .queue( "EMAILS", { payload: z.object({ to: z.string().email(), subject: z.string(), body: z.string(), }), }, async (ctx) => { await sendEmail(ctx.message.payload); ctx.ack(); }, ) .queue( "TASKS", { payload: z.object({ task: z.string(), priority: z.enum(["low", "medium", "high"]), }), }, async (ctx) => { await processTask(ctx.message.payload); ctx.ack(); }, ) .build(); ``` ## Using State in Queues Access state from `.state()` calls in queue handlers: ```typescript import { withDrizzle } from "@meetploy/start"; const worker = ploy() .state(withDrizzle("DB", schema)) .queue( "ORDERS", { payload: z.object({ orderId: z.string() }), }, async (ctx) => { // Access database via state const order = await ctx.state.db .select() .from(orders) .where(eq(orders.id, ctx.message.payload.orderId)) .limit(1); if (order[0]) { await processOrder(order[0]); } ctx.ack(); }, ) .build(); ``` ## Full Example ```typescript import { ploy, withDrizzle, z } from "@meetploy/start"; import { eq } from "drizzle-orm"; import * as schema from "./schema.js"; const worker = ploy() .state(withDrizzle("DB", schema)) // Send message endpoint .post( "/orders", { body: z.object({ userId: z.string(), items: z.array( z.object({ productId: z.string(), quantity: z.number(), }), ), }), response: z.object({ orderId: z.string(), messageId: z.string(), }), }, async (ctx) => { // Create order in database const orderId = crypto.randomUUID(); // Queue background processing const { messageId } = await ctx.env.ORDER_QUEUE.send({ action: "process", orderId, userId: ctx.body.userId, items: ctx.body.items, }); return { orderId, messageId }; }, ) // Queue handler .queue( "ORDER_QUEUE", { payload: z.object({ action: z.enum(["process", "cancel", "refund"]), orderId: z.string(), userId: z.string(), items: z .array( z.object({ productId: z.string(), quantity: z.number(), }), ) .optional(), }), }, async (ctx) => { const { action, orderId, userId } = ctx.message.payload; console.log(`Processing ${action} for order ${orderId}`); try { switch (action) { case "process": await processOrder(orderId, ctx.message.payload.items!); break; case "cancel": await cancelOrder(orderId); break; case "refund": await refundOrder(orderId); break; } ctx.ack(); } catch (error) { if (ctx.message.attempt < 3) { ctx.retry(ctx.message.attempt * 1000); } else { ctx.deadLetter(`Failed after ${ctx.message.attempt} attempts`); } } }, ) .build(); export default worker; ``` ## Best Practices * **Keep handlers idempotent** - Messages may be delivered more than once * **Use unique IDs** - Include IDs in payloads to detect duplicates * **Set appropriate retries** - Don't retry indefinitely * **Log message IDs** - For debugging and tracing * **Validate payloads** - Zod validation catches malformed messages * **Handle partial failures** - Design for resilience # Routes URL: https://docs.meetploy.com/start/routes # Routes Ploy Start provides a type-safe routing system with automatic Zod validation for params, query strings, request bodies, and responses. ## Basic Routes Define routes using HTTP method shortcuts: ```typescript import { ploy, z } from "@meetploy/start"; const worker = ploy() .get( "/hello", { response: z.object({ message: z.string() }), }, async () => { return { message: "Hello World!" }; }, ) .build(); ``` ## Route Methods All standard HTTP methods are supported: ```typescript const worker = ploy() .get("/users", {...}, handler) // GET request .post("/users", {...}, handler) // POST request .put("/users/:id", {...}, handler) // PUT request .patch("/users/:id", {...}, handler) // PATCH request .delete("/users/:id", {...}, handler) // DELETE request .build(); ``` ## Path Parameters Define path parameters with `:param` syntax: ```typescript .get("/users/:id", { params: z.object({ id: z.string() }), response: z.object({ user: z.object({ id: z.string(), name: z.string() }).nullable() }) }, async (ctx) => { // ctx.params.id is typed as string const userId = ctx.params.id; return { user: await findUser(userId) }; }) ``` Multiple path parameters: ```typescript .get("/orgs/:orgId/users/:userId", { params: z.object({ orgId: z.string(), userId: z.string() }), response: z.object({ user: userSchema }) }, async (ctx) => { // ctx.params.orgId and ctx.params.userId are both typed return { user: await findOrgUser(ctx.params.orgId, ctx.params.userId) }; }) ``` ## Query Parameters Validate query strings with Zod: ```typescript .get("/users", { query: z.object({ page: z.string().optional().default("1"), limit: z.string().optional().default("10"), search: z.string().optional() }), response: z.object({ users: z.array(userSchema), total: z.number() }) }, async (ctx) => { const { page, limit, search } = ctx.query; // All query params are typed return { users: [], total: 0 }; }) ``` Query parameters are always strings from the URL. Use `.transform()` to convert them: ```typescript query: z.object({ page: z.string().transform(Number).default("1") }) ``` ## Request Body Handle JSON request bodies on POST, PUT, and PATCH: ```typescript .post("/users", { body: z.object({ name: z.string().min(1), email: z.string().email(), age: z.number().optional() }), response: z.object({ success: z.boolean(), user: userSchema }) }, async (ctx) => { // ctx.body is fully typed const { name, email, age } = ctx.body; const user = await createUser({ name, email, age }); return { success: true, user }; }) ``` ## Response Schema Every route requires a response schema for type safety and OpenAPI generation: ```typescript .get("/health", { response: z.object({ status: z.enum(["ok", "degraded", "down"]), timestamp: z.string() }) }, async () => { return { status: "ok", timestamp: new Date().toISOString() }; }) ``` ## Route Context Every handler receives a typed context object: ```typescript interface RouteContext { request: Request; // Raw Cloudflare Request env: Env; // Environment bindings ctx: ExecutionContext; // Cloudflare ExecutionContext state: State; // State from .state() calls params: Params; // Validated path parameters query: Query; // Validated query parameters body: Body; // Validated request body json: (data, status?) => Response; text: (data, status?) => Response; html: (data, status?) => Response; redirect: (url, status?) => Response; } ``` ### Response Helpers ```typescript .get("/example", {...}, async (ctx) => { // Return JSON (default) return { data: "value" }; // Or use helpers for other response types return ctx.json({ data: "value" }, 201); return ctx.text("Plain text response"); return ctx.html("

HTML Response

"); return ctx.redirect("/other-page", 302); }) ``` ## Accessing Environment Access Cloudflare bindings via `ctx.env`: ```typescript import { ploy, z } from "@meetploy/start"; const worker = ploy() .get( "/send", { response: z.object({ messageId: z.string() }), }, async (ctx) => { // Access queue binding directly const { messageId } = await ctx.env.TASKS.send({ task: "process" }); return { messageId }; }, ) .build(); ``` ## OpenAPI Documentation Enable automatic OpenAPI spec generation: ```typescript const worker = ploy() .openapi({ path: "/docs", info: { title: "My API", version: "1.0.0", description: "API description", }, }) .get( "/users", { summary: "List all users", description: "Returns a paginated list of users", tags: ["Users"], response: z.object({ users: z.array(userSchema) }), }, handler, ) .build(); ``` Access the OpenAPI spec at `/docs` and Swagger UI at `/docs/ui`. ## Middleware Add global middleware with `.use()`: ```typescript import { cors, logger, auth } from "@meetploy/start"; const worker = ploy() .use(cors()) .use(logger()) .use(auth("Authorization")) .get("/protected", {...}, handler) .build(); ``` ### Built-in Middleware ```typescript // CORS - Handle cross-origin requests cors({ origin: "*", methods: ["GET", "POST", "PUT", "DELETE"], }); // Logger - Log requests logger(); // Auth - Extract auth header auth("Authorization"); // Rate Limit - Basic rate limiting rateLimit({ maxRequests: 100, windowMs: 60000 }); ``` ### Custom Middleware ```typescript const worker = ploy() .use(async (ctx, next) => { const start = Date.now(); const response = await next(); const duration = Date.now() - start; console.log(`${ctx.request.method} ${ctx.request.url} - ${duration}ms`); return response; }) .build(); ``` ## State Management Add shared state with `.state()`: ```typescript import { withDrizzle } from "@meetploy/start"; const worker = ploy() .state(withDrizzle("DB", schema)) .state((ctx) => ({ requestId: crypto.randomUUID() })) .get("/users", {...}, async (ctx) => { // ctx.state.db from withDrizzle // ctx.state.requestId from custom state const users = await ctx.state.db.select().from(schema.users); return { users, requestId: ctx.state.requestId }; }) .build(); ``` State factories receive `{ env, request }` and can be async: ```typescript .state(async ({ env, request }) => { const user = await validateToken(request.headers.get("Authorization")); return { user }; }) ``` ## Error Handling Errors thrown in handlers are caught and returned as 500 responses: ```typescript .get("/error", {...}, async () => { throw new Error("Something went wrong"); // Returns: { error: "Something went wrong" } with status 500 }) ``` For custom error responses, return them directly: ```typescript .get("/users/:id", {...}, async (ctx) => { const user = await findUser(ctx.params.id); if (!user) { return ctx.json({ error: "User not found" }, 404); } return { user }; }) ``` # Workflows URL: https://docs.meetploy.com/start/workflows # Workflows Ploy Start provides type-safe workflow handlers for building durable, multi-step processes with automatic state persistence and retries. ## Setup ### 1. Configure Workflow Binding Add a workflow binding to your `ploy.yaml`: ```yaml title="ploy.yaml" kind: worker build: pnpm build out: dist workflow: ORDER_FLOW: order_processing ``` The key is the binding name (accessed via `ctx.env`), the value is the workflow function name. You can use the expanded form to configure per-workflow retry defaults: ```yaml title="ploy.yaml" workflow: ORDER_FLOW: name: order_processing retries: 5 # Retry failed steps up to 5 times (default: 3) ``` ### 2. Generate Types ```bash pnpm types ``` This creates `env.d.ts` with your workflow binding typed as `WorkflowBinding`. ## Defining Workflows Use `.workflow()` to define a type-safe workflow: ```typescript title="src/index.ts" import { ploy, z } from "@meetploy/start"; const worker = ploy() .workflow( "order_processing", { input: z.object({ orderId: z.string(), amount: z.number(), }), output: z.object({ success: z.boolean(), trackingNumber: z.string(), }), }, async (ctx) => { // Workflow steps... return { success: true, trackingNumber: "TRACK123" }; }, ) .build(); export default worker; ``` The workflow name in `.workflow()` must match the value in your `ploy.yaml` (e.g., "order\_processing"). ## Workflow Context The workflow handler receives a typed context: ```typescript interface EnhancedWorkflowContext { input: Input; // Validated workflow input env: Env; // Environment bindings executionId: string; // Unique execution ID step: { run: (name, fn, opts?) => Promise; sleep: (duration) => Promise; parallel: (name, fns) => Promise; }; log: (message, data?) => void; } ``` ## Inputs and Outputs Every workflow has a durable execution record: * **Execution input** comes from `ctx.env.ORDER_FLOW.trigger(...)` * **Execution output** is the object returned by the workflow handler Each `ctx.step.run()` can also persist step-level snapshots: * **Step input** comes from the optional `input` field in step options * **Step output** is the value returned by the step callback ```typescript const validation = await ctx.step.run( "validate", async () => { return { valid: true, orderId: ctx.input.orderId, }; }, { input: { orderId: ctx.input.orderId, amount: ctx.input.amount, }, }, ); ``` Only data you pass through the step `input` option is captured as step input. This is useful for debugging transformed payloads, retries, and fan-out steps. ## Step Functions ### ctx.step.run Execute a named step with automatic persistence: ```typescript .workflow("order_processing", {...}, async (ctx) => { // Step 1: Validate const validation = await ctx.step.run("validate", async () => { if (!ctx.input.orderId) { throw new Error("Missing orderId"); } return { valid: true, orderId: ctx.input.orderId }; }); // Step 2: Charge payment const payment = await ctx.step.run("charge", async () => { return { paymentId: `pay_${Date.now()}`, status: "paid" }; }); // Step 3: Fulfill const fulfillment = await ctx.step.run("fulfill", async () => { return { trackingNumber: `TRACK_${Date.now()}` }; }); return { success: true, trackingNumber: fulfillment.trackingNumber }; }) ``` Each step is persisted. If the workflow restarts, completed steps are skipped and their results are replayed from storage. ### Step Options Configure per-step retries and timeouts (overrides the workflow-level default): ```typescript await ctx.step.run( "external-api", async () => { return await callExternalAPI(); }, { retries: 5, // Retry up to 5 times (overrides ploy.yaml default) timeout: 30000, // Timeout after 30 seconds }, ); ``` By default, each step retries up to **3 times** on failure with exponential backoff. Configure the default in `ploy.yaml` or override per-step with the `retries` option. ### ctx.step.sleep Pause execution for a duration: ```typescript // Sleep for milliseconds await ctx.step.sleep(5000); // 5 seconds // Sleep for human-readable duration await ctx.step.sleep("30s"); // 30 seconds await ctx.step.sleep("5m"); // 5 minutes await ctx.step.sleep("1h"); // 1 hour ``` Sleep is durable - the workflow state is saved and resumed after the duration. ### ctx.step.parallel Run multiple operations in parallel: ```typescript const [userResult, orderResult, inventoryResult] = await ctx.step.parallel( "fetch-data", [ async () => await fetchUser(ctx.input.userId), async () => await fetchOrder(ctx.input.orderId), async () => await checkInventory(ctx.input.items), ], ); ``` ## Triggering Workflows Trigger workflows via the binding in HTTP routes: ### Start Execution ```typescript .get("/workflow/trigger", { response: z.object({ executionId: z.string() }) }, async (ctx) => { const { executionId } = await ctx.env.ORDER_FLOW.trigger({ orderId: "order-123", amount: 99.99 }); return { executionId }; }) ``` ### Check Status ```typescript .get("/workflow/status/:id", { params: z.object({ id: z.string() }), response: z.object({ execution: z.unknown() }) }, async (ctx) => { const execution = await ctx.env.ORDER_FLOW.getExecution(ctx.params.id); return { execution }; }) ``` ## Inspecting Executions After you trigger a workflow locally, open the Ploy dashboard to inspect recent executions and step history. [Screenshot: Workflow executions list in the local Ploy dashboard] The execution detail screen shows execution input/output plus the step timeline, step inputs, step outputs, durations, and retry attempts. In `examples/workflow-simple`, use `/trigger` for a successful execution and `/trigger-failing` for a run that exhausts every retry in `charge_payment`, so you can inspect the final failed-step state in the UI. [Screenshot: Workflow execution detail view showing execution and step inputs and outputs] ### Cancel Execution ```typescript .get("/workflow/cancel/:id", { params: z.object({ id: z.string() }), response: z.object({ cancelled: z.boolean() }) }, async (ctx) => { await ctx.env.ORDER_FLOW.cancel(ctx.params.id); return { cancelled: true }; }) ``` ## Error Handling Handle errors in individual steps: ```typescript .workflow("order_processing", {...}, async (ctx) => { // Retry payment with backoff let payment; for (let attempt = 0; attempt < 3; attempt++) { payment = await ctx.step.run(`charge_attempt_${attempt}`, async () => { const result = await chargeCard(ctx.input.amount); if (result.status === "declined") { throw new Error("Payment declined"); } return result; }); if (payment.status === "paid") break; // Wait before retry await ctx.step.sleep(attempt * 1000); } if (!payment || payment.status !== "paid") { throw new Error("Payment failed after retries"); } return { success: true, paymentId: payment.id }; }) ``` ## Full Example ```typescript import { ploy } from "@meetploy/start"; import { z } from "zod"; const worker = ploy() // Trigger endpoint .post( "/orders", { body: z.object({ userId: z.string(), items: z.array( z.object({ productId: z.string(), quantity: z.number(), price: z.number(), }), ), }), response: z.object({ orderId: z.string(), executionId: z.string(), }), }, async (ctx) => { const orderId = crypto.randomUUID(); const amount = ctx.body.items.reduce( (sum, item) => sum + item.price * item.quantity, 0, ); const { executionId } = await ctx.env.ORDER_FLOW.trigger({ orderId, userId: ctx.body.userId, items: ctx.body.items, amount, }); return { orderId, executionId }; }, ) // Status endpoint .get( "/orders/:id/status", { params: z.object({ id: z.string() }), response: z.object({ status: z.string(), result: z.unknown().optional(), }), }, async (ctx) => { const execution = await ctx.env.ORDER_FLOW.getExecution(ctx.params.id); return { status: execution.status, result: execution.result, }; }, ) // Workflow definition .workflow( "order_processing", { input: z.object({ orderId: z.string(), userId: z.string(), items: z.array( z.object({ productId: z.string(), quantity: z.number(), price: z.number(), }), ), amount: z.number(), }), output: z.object({ orderId: z.string(), paymentId: z.string(), trackingNumber: z.string(), completedAt: z.string(), }), }, async (ctx) => { ctx.log("Starting order processing", { orderId: ctx.input.orderId }); // Step 1: Validate inventory const inventory = await ctx.step.run("check-inventory", async () => { for (const item of ctx.input.items) { const available = await checkStock(item.productId); if (available < item.quantity) { throw new Error(`Insufficient stock for ${item.productId}`); } } return { available: true }; }); // Step 2: Reserve inventory await ctx.step.run("reserve-inventory", async () => { for (const item of ctx.input.items) { await reserveStock(item.productId, item.quantity); } }); // Step 3: Process payment with retry let payment; for (let attempt = 0; attempt < 3; attempt++) { payment = await ctx.step.run(`payment-attempt-${attempt}`, async () => { return await processPayment(ctx.input.userId, ctx.input.amount); }); if (payment.status === "succeeded") break; await ctx.step.sleep("5s"); } if (!payment || payment.status !== "succeeded") { // Rollback inventory reservation await ctx.step.run("rollback-inventory", async () => { for (const item of ctx.input.items) { await releaseStock(item.productId, item.quantity); } }); throw new Error("Payment failed"); } // Step 4: Create shipment const shipment = await ctx.step.run("create-shipment", async () => { return await createShipment(ctx.input.orderId, ctx.input.items); }); // Step 5: Send confirmation email await ctx.step.run("send-confirmation", async () => { await sendEmail(ctx.input.userId, { subject: "Order Confirmed", body: `Your order ${ctx.input.orderId} is on its way!`, }); }); // Step 6: Wait and send follow-up await ctx.step.sleep("24h"); await ctx.step.run("send-followup", async () => { await sendEmail(ctx.input.userId, { subject: "How was your order?", body: "We hope you enjoyed your purchase!", }); }); return { orderId: ctx.input.orderId, paymentId: payment.id, trackingNumber: shipment.trackingNumber, completedAt: new Date().toISOString(), }; }, ) .build(); export default worker; ``` ## Best Practices * **Use unique step names** - Each step needs a unique name for replay * **Keep steps atomic** - One logical operation per step * **Handle failures gracefully** - Use try/catch and implement rollback logic * **Log progress** - Use `ctx.log()` for debugging * **Set timeouts** - Prevent steps from hanging indefinitely * **Design for idempotency** - Steps may be retried