Asking an AI? Paste this URL https://keelson.dev/llms.txt
All Documentation
Introduction
Documentation guide
Keelson helps your team use internal apps built with AI tools or other development tools. Along with deploying and running your app, it handles login and lets you choose which members can access it.
To get started, create an account or sign in to the Keelson console.
You can ask questions about Keelson in ChatGPT, Claude Code, or another AI tool you use. Click Copy in the bar at the top of the page and send the URL along with your question. Describe how your app is built to ask whether it can run on Keelson and what changes it needs.
Try the following prompt, replacing the app description with your own:
https://keelson.dev/llms.txt
Read the documentation at this URL and follow relevant links as needed.
I want to deploy an internal app built with Python and SQLite to Keelson.
Tell me whether it is supported and what I need to prepare.
Keelson supports Python, Node.js, and Go web apps, prebuilt static sites, and scheduled jobs that run at set times or intervals. It is designed for internal business apps used by a few people to a few hundred people. The number of app users does not affect the price.
Apps that require an always-running server, WebSockets, or a GPU are not supported. See Supported apps and constraints for frameworks and detailed requirements.
Existing apps that meet these requirements can be deployed after adjusting their configuration and any code that depends on the runtime environment. You do not need to rewrite the entire app for Keelson. You can also keep using existing external databases and services. See Bring an existing app for the preparation steps.
For considerations when moving an app to another environment, see the FAQ.
Visit the main website for an overview and pricing.
The Keelson Deploy Spec describes the requirements and configuration for deploying an app. Give your AI agent the following URL and ask: “Read this specification and review my app’s code, then suggest the changes needed to deploy it to Keelson.”
The Agent Skill installed by keelson install-agent includes the Deploy Spec and instructions for adapting Python, Node.js, and Go code.
To give an AI tool all the documentation in one file, use /llms-full.txt. To get an individual page as Markdown, replace the trailing / in its URL with .md (for example, /docs/introduction/quickstart/ → /docs/introduction/quickstart.md).
See the FAQ for common questions before getting started.
Quickstart
Deploy a sample app to Keelson and check that only signed-in users can open it.
Time: about 15 minutes, including the build.
What you need: a Keelson account and a terminal for running commands. The sample does not need API keys for any external services.
What success looks like: you see a greeting after signing in, while a private browser window where you are not signed in shows the login page.
This guide uses Claude Code, Codex, or Cursor to deploy the app. You can also deploy directly with the CLI: skip step 3 and run the CLI command in step 4.
If you do not have a Keelson account, create one in the Keelson console.
The installer does not change PATH, the list of places your terminal looks for commands. Add this line to ~/.zshrc or ~/.bashrc, then reopen your terminal:
Terminal window
export PATH="$HOME/.keelson/bin:$PATH"
Run this in Windows PowerShell 5.1 or later, included with Windows 10, Windows 11, and Windows Server 2016 or later.
Terminal window
irm https://keelson.dev/install.ps1 | iex
After installation, make keelson available in your current PowerShell session:
Make the command available in future PowerShell sessions
The installer does not change PATH. Open Edit environment variables for your account, add %USERPROFILE%\.keelson\bin to your user Path, then reopen PowerShell.
Next, run the login command. Your browser opens so you can sign in with your Keelson account.
The Agent Skill gives your AI agent Keelson configuration and deployment instructions. It comes with the Keelson CLI. Open a terminal in the extracted keelson-hello folder, select your agent, and run the command.
With --global, the skill is available in your other projects too. Without it, the skill is installed only in the current project.
Terminal window
keelsoninstall-agentcodex
Installs the skill in the current project.
Terminal window
keelsoninstall-agentcursor
Installs the skill in the current project.
Install the Skill from the public repository
Choose exactly one installation method: keelson install-agent, the Claude Code plugin, or npx skills add. Combining methods can create two copies of the same Skill or cause one installation to overwrite the other.
In Claude Code, add the marketplace and install the plugin:
/plugin marketplace add keelsonhq/skills
/plugin install keelson@keelson-skills
For other agents, use:
Terminal window
npxskillsaddkeelsonhq/skills
These methods install only the Skill. You still need to install the CLI and sign in as described in step 1. A Skill installed as a Claude Code plugin is not updated by keelson login; it follows Claude Code plugin updates instead.
See CLI commands for installation locations and how to update the skill.
The agent starts the deployment. The first deployment takes a few minutes. When it starts, you receive a deployment ID and an app URL in this format:
https://<workspace>--hello.keelson.run
If you are not using an AI agent, run this in the keelson-hello folder:
Terminal window
keelsondeploy--new
The URL is issued before deployment finishes, so the app may still be building or starting when it appears. Before opening the URL, check the status. Replace <deploy-id> with the ID shown when deployment started.
Terminal window
keelsonstatus<deploy-id>
For example, for ID d1, run keelson status d1. A result of status: completed means the deployment process has finished. Continue to step 5 to check that you can actually open the app.
Statuses such as queued, building_image, or starting mean the deployment is still in progress. Wait a little and run the same command again. failed or rolled_back means it did not succeed: check the reason and suggested next action. See Troubleshooting for help.
Share with a colleague — invite them and check that they can open the app using their own account. See Share an app with your team.
Change the page and deploy again — edit the text in main.py and ask the agent to deploy again. See Update and restore for applying changes and returning to an earlier version.
A business tool you deploy and use, such as a sales tracker or daily report app
Member
A person who belongs to a workspace
Group
A collection of members, such as a department or team, that you can assign access to together
Deploy
Send your app’s code and configuration to Keelson so it can run there
For example, you could create a sales report app and an expense request app in the same workspace, then share the sales report with the sales team and the expense app with all employees.
Deploying a web app or static site gives it a dedicated HTTPS URL. Keelson obtains and renews the certificate.
https://<workspace>--<app>.keelson.run
Knowing the URL is not enough to open a regular app page. Keelson checks that the visitor is signed in and has permission to view the app, then passes authorized requests to it.
Browser
↓
Keelson: checks login, app access, and IP restrictions
↓
App: displays pages and runs business logic
Your app does not need to implement a login page or manage Keelson login sessions. It can identify the visitor using the user information Keelson supplies.
Requests that do not use browser login, such as webhooks from external services, need separate configuration. See External integrations.
After signing in once, a user can open any app they have access to in the same workspace. They do not need to sign in to Keelson separately for each app. If an app also uses its own external authentication, that service may require a separate login.
App management permission is separate from business approval authority. Rules such as “only a manager can approve a request” or “only the assigned person can edit a case” belong in your app, using user and group information.
What Keelson provides and what your app implements
For storage, you can use Managed SQLite, a dedicated database managed by Keelson, and the Files / Media SDK, libraries for storing and retrieving files, images, and videos. Responsibilities are divided as follows:
Feature
Keelson provides
Your app implements
Pages and business logic
An app URL and runtime environment
Forms, reports, approval requests, and other features
Login and permissions
Login, member management, and checks on who can open an app
Business rules, such as showing only the current user’s data
Data storage
Managed SQLite and the Files / Media SDK
Code that connects to storage and reads and writes data
API keys and other secrets
Storage and delivery of secrets to the app
Code that uses the supplied keys to connect to external services
Scheduled jobs
Starting work at configured times or intervals
Code that performs one run of the task and exits
Operations
Logs, app listings, and Managed SQLite backup and restore
Logging business outcomes and errors
You can describe the features you want to an AI agent and ask it to create the app code and Keelson configuration.
Set shared API keys at the workspace level and pass them to apps as environment variables. An app can override a shared value with its own. You do not need to put secret values in source code or keelson.yaml.
Add this to keelson.yaml to provision a dedicated Managed SQLite database:
db:
mode: libsql
Keelson supplies the connection information. Your app implements the code to connect and read or write data.
Managed SQLite includes backup and restore. Files / Media files are not included in those backups. See Databases and Files and media to choose the right storage.
Configure daily reports or recurring data synchronization under crons in keelson.yaml. Jobs can run even when no one is visiting the web app. See Scheduled jobs.
The console shows who created each app, who can use it, and when it was last used or deployed. Other members can understand how an app is managed without relying on its creator.
Web apps stop after a period without requests. They start again on the next request, so the first response after stopping may take longer. Work that continues after a response has been sent is not guaranteed to finish.
Files on the app server are temporary. They can be lost on restart or redeployment. Store data you need to keep in Managed SQLite or through the Files / Media SDK.
It depends on how the app is built. Start by asking the AI tool that created it, or an AI agent that can inspect its code. You can copy this prompt:
https://keelson.dev/llms.txt
Read the documentation at this URL and follow relevant links as needed.
Check whether this app can be deployed to Keelson.
Review its code, configuration, database, authentication, and external services,
then explain the changes and preparation needed.
Ask me if you need more information to make that assessment.
Prebuilt HTML, CSS, and JavaScript can be deployed as a static site. Apps with server-side processing, such as Next.js apps, can be deployed as web apps. Build static sites locally and configure their external service URLs before deploying.
You can keep databases and storage that accept external connections, but data and authentication tied to the app-building tool do not move automatically. See Bring an existing app.
Yes. With the configuration and code changes needed for the destination, your app can run locally, on another hosting service, or on your own server.
Provide the runtime, such as Python or Node.js, install dependencies, and configure startup and database connections for the new environment.
If you still need the login, access controls, and scheduled jobs that Keelson handled, you must provide them in the new environment. Adapt code that reads Keelson’s signed-in user information to your new authentication method. Code using the Files / Media SDK also needs changes for the destination’s storage service, along with migration of the files.
Not currently. Streamlit requires WebSockets, which do not fit Keelson’s execution model. Gradio is under evaluation. See Supported apps and constraints.
Yes, as long as it connects to an external database such as PostgreSQL. Managed SQLite (libSQL) has no backend for the Django ORM, so Django with Managed SQLite is not supported. Set db.mode: none, pass the connection details as secrets, and start it with gunicorn. See Framework notes.
No. Web apps stop after a period without requests and start again on the next request. The first response after stopping may therefore take longer.
Use scheduled jobs for work that must run at a set time. Work that continues after a response has been sent is not guaranteed to finish. See When apps run.
For first-time users, billing starts when the 14-day free trial ends and the workspace moves to a paid subscription.
Starter / Plus: you can try these without a payment card. If you do not add a card before the trial ends, you are not charged. If a card is on file, your paid subscription starts and the first charge occurs when the trial ends.
Team: a payment card is required to start the trial. Cancel during the trial to avoid a charge. If you continue, the first charge occurs when the trial ends.
If the trial ends without a paid subscription, new deployments, app startup, and scheduled jobs stop being available. See What happens after a trial or subscription ends? below for how data is handled.
If your account has already used its trial and you create another workspace, billing starts when payment completes. See Billing and subscriptions.
Plus or higher. Developer seats are for members who build and manage apps. Two people doing that work need two seats. The 50 people using the apps can join as App Users, and their number does not affect the price.
Owner, Admin, and Developer roles count toward developer seats. Also check that the concurrent app limit fits your needs: Starter allows 1, Plus 2, and Team 4. See Plans and limits.
If I keep 10 apps but only use a few at a time, which limit matters?
Paid subscriptions have no limit on the number of apps stored in a workspace. The app-count limit applies to apps used concurrently.
Apps accessed within the last five minutes occupy a concurrent app slot. When requests stop, an app becomes sleeping and its slot is released.
During the trial, a workspace can also hold at most three apps. Apps already deployed as static sites do not count toward those three. See Concurrent apps.
What do users see when all concurrent app slots are occupied?
Someone opening a sleeping app sees a page asking them to wait for a slot. An unreserved slot becomes available about five minutes after the last request to the app using it. It stays occupied while that app continues to receive requests. Priority startup can reserve a slot for an important app (Plus and higher on paid subscriptions; all plans during the trial).
Are external API costs, such as OpenAI usage, included?
No. External API usage is billed separately under your agreement with that provider. Store the API key as a secret for your app to use. See External integrations.
No. A visitor must be signed in as a workspace member and have permission to view that app. Visitors who are not signed in see the login page. To share with someone outside your company, invite them as a member; App Users are unlimited. See Authentication and login.
Where do apps and databases run? Are databases backed up?
Apps and their Managed SQLite databases are hosted in the region chosen when the app is created. The default is Japan.
Japan is currently the only region available. US West is in preparation; selecting it before release is rejected.
Managed SQLite includes backup and restore on every plan. Files stored through the Files / Media SDK and external databases are not included in these backups.
Daily backups, manual backups, point-in-time recovery (PITR), and downloads are available. See Backup and restore.
A normal redeployment does not erase data in Managed SQLite or files stored through the Files SDK or Media SDK. Local files on the app server, however, are lost on restart or redeployment.
If a database migration or your app code changes or deletes data, those changes take effect. See Databases for storage options and Update and restore for recovery.
If a trial ends without a paid subscription, or a canceled subscription reaches the end of its term, new deployments, app startup, and scheduled jobs become unavailable. Apps and data are not deleted immediately.
Owners and Admins receive deletion notices in the console and by email for affected apps. From the time the notice is recorded, the grace period is three days for a workspace that has never paid, or 30 days for a workspace with a payment history. Check the notice for the scheduled deletion date.
Starting or resuming a paid subscription within the grace period cancels the notice and keeps the apps and data. Without a subscription, affected apps are automatically deleted after the grace period. Database backups and Files files are then retained in isolation from normal use for seven days, but recovery is not guaranteed.
Managed SQLite backups can still be downloaded during the grace period. Export Files / Media files before the subscription ends. See Apps and data after cancellation.
A downgrade during a paid subscription takes effect at the end of the current billing period. You keep the current plan until then. Plan changes during a trial take effect immediately.
Changing plans does not itself delete apps or data. Usage may be restricted if you exceed the new plan’s limits. For example, sleeping apps cannot start while all concurrent app slots are occupied, and you cannot add members who build or manage apps while over the developer seat limit.
Keelson supports Python, Node.js, and Go web apps, static sites, and scheduled jobs.
Depending on your framework and storage choices, you may need to change code or configuration. Check the table below.
You do not need a Dockerfile. Keelson does not use one for its build, even if your app includes one.
Start on the port specified by PORT. Use gunicorn for Flask
Node.js web apps (Express, Hono, Next.js, and others)
Supported
Start on the port specified by PORT. Use next start for Next.js
Go web apps (net/http, Gin, and others)
Supported
Keelson builds the app and starts it with ./app
Prebuilt static sites / SPAs (React, Vue, Svelte, and others)
Supported
Point assets to the build output
Static frontend + API server (hybrid)
Supported
Set the API path with assets.api
Scheduled jobs only
Supported
Set crons alongside the shared required fields; omit the web server command
Connections to external PostgreSQL / MySQL / libSQL
Supported
Set db.mode: none. Supply connection details through secrets
Calls to external APIs (OpenAI, Slack, Google Sheets, and others)
Supported
Outbound connections are unrestricted, except that the SMTP ports (TCP 25, 465, 587, 2525) are always blocked
Persistent data in SQLite files (sqlite3, better-sqlite3, and others)
Changes required
Switch the connection to a libSQL client and set db.mode: libsql. SQL usually stays as it is (Managed SQLite is SQLite-compatible), but adapt to client API differences (async in Node.js, row_factory and others in Python). Local development in Python / Node.js keeps working with file:local.db
Data that must persist stored in local files
Changes required
Use Managed SQLite or the Files SDK / Media SDK. Local data written during execution does not survive restarts
In-app timers or background threads
Changes required
Move work that runs at set times to scheduled jobs, and enqueue work that continues after a request as a background task (How apps run)
Django
External database only
Connect to an external database such as PostgreSQL with db.mode: none and start with gunicorn. Managed SQLite (libSQL) has no backend for the Django ORM, so Django with Managed SQLite is not supported (Framework notes)
Streamlit
Unsupported
It requires WebSocket and currently does not work (Framework notes)
Gradio
Under evaluation
Version 4 and later are being evaluated. Operation is not currently guaranteed
Ruby / PHP / Java / Rust / .NET / Elixir
Unsupported
Only Python, Node.js, and Go runtimes are available
Adding apt packages through a Dockerfile
Unsupported
No root access. A -media runtime may cover image or video requirements
Apps requiring continuously running processes, daemons, or WebSocket servers
Unsupported
Incompatible with the execution model, which stops idle apps
Running your own Redis / Kafka / database server
Unsupported
Connecting to an external service is allowed
GPU workloads
Unsupported
Apps marked “Changes required” may work after code or configuration changes. Ask your AI agent to review the app and propose the necessary changes.
You can store temporary data that can be regenerated in local files. Temporary SQLite use requires a db.local_sqlite declaration. See the keelson.yaml reference for requirements.
Select a runtime with runtime in keelson.yaml. Start with -slim if you are unsure, and switch to -media if you need image, video, or PDF processing libraries such as Pillow, sharp, or ffmpeg.
Runtime
Language version
Build behavior
python-slim / python-media
Python 3.12
Automatically installs dependencies from requirements.txt or pyproject.toml
node-slim / node-media
Node.js 24
Automatically installs dependencies from package.json and package-lock.json. Runs npm run build if defined
go-slim / go-media
Go 1.26
Builds from go.mod using go build and starts as ./app
The Go version is the toolchain used for the build. The build steps in this table apply to apps with server-side processing and scheduled jobs. Dependencies are installed at build time. Put only the startup command in command.
Node.js: Keelson uses npm for builds on the platform. Projects with only pnpm-lock.yaml or yarn.lock are unsupported; provide package-lock.json. Static sites built locally with pnpm or Yarn can still be served.
Go: Builds use CGO_ENABLED=0. Libraries that depend on cgo cannot be used.
This page explains how apps with server-side processing run. Static sites do not need an app server to start. In a hybrid app that combines a static frontend with an API server, the startup and shutdown behavior below applies to the API server.
Keelson starts your app server when it receives traffic and stops it after a period without traffic. The next request starts it again.
Handle page loads and API calls within requests, enqueue work that continues after a request as a background task, and use Keelson scheduled jobs for work such as daily reports. Work that assumes the app is always running is not supported.
When a request arrives while an app is stopped, Keelson starts the app. The first request after a shutdown may therefore take longer than usual.
After a restart, variables held in memory and data written to local files during execution are lost. Save data you need to keep in Managed SQLite or with the Files / Media SDK.
Complete all work needed for a request, including saving data and calling external APIs, before returning the response.
Background work that continues after the response is sent is not guaranteed to finish. If your app uses FastAPI BackgroundTasks or starts asynchronous work without waiting for it, you need to change how that work runs: await short work before returning the response, and enqueue work that must continue after the response as a background task.
You can use streaming to return results incrementally, such as an AI-generated answer. Streaming does not provide a way to keep processing after the connection closes.
Declare work such as heavy external API calls, PDF or image generation, or anything that exceeds the request time limit under tasks in keelson.yaml, and enqueue it from your app with the SDK. Keelson runs the task’s command once in an execution environment separate from the web app and retries it automatically if it fails.
A task may run more than once, so write it so that running it twice does no harm. See Background tasks for setup instructions.
Configure daily reports or periodic data synchronization in the crons section of keelson.yaml. These jobs run on schedule even when nobody is using the web app.
Each job starts in an execution environment separate from the web app and exits when its command finishes. Save data shared with the web app in a database or another persistent store.
Timers and schedulers inside the app do not run while the app is stopped. Use Keelson scheduled jobs for recurring work.
Collect work and process it later in batches (a wait is acceptable)
Save pending work in the database and process it with a scheduled job
Saving work for later introduces a delay until the next job runs. Use a background task for work that should start right away. In either case, you need to handle duplicate processing and failures. See Background tasks and Job design rules.
Keelson does not support continuously running workers. Enqueue work that continues after a request as a background task.
HTTP requests, background tasks, and scheduled jobs each have a time limit. Split long-running work into units that finish within the applicable limit.
You can bring apps built with Lovable, Bolt, v0, or local development tools to Keelson if you have their source code. Compatibility and the changes needed for deployment depend on the app’s language, structure, and data storage.
Start by asking an AI agent to review the code, then review the required changes before deploying. Deploying the code does not move data or user accounts from external services to Keelson.
Export the code from the tool you used to build the app, or clone its Git repository locally. Open the app’s folder in your AI agent.
If you have not installed the Keelson CLI and Agent Skill or signed in to Keelson, follow steps 1 and 3 of the quickstart. When installing the Skill per project, run the installation in the folder of the app you are bringing over, rather than the sample app folder.
I want to deploy this app to Keelson. Start at https://keelson.dev/llms.txt and read the linked supported apps and constraints page and Keelson Deploy Spec. Then review the code and explain whether it can be deployed and what needs to change. Check the database, login, file storage, and recurring tasks. Explain any changes that affect existing data or users’ access before making them.
Your agent will mainly check these areas:
What to check
Why
Language and framework
To confirm support and determine how to build and start the app
Data storage
To decide whether to keep the external database or switch to Managed SQLite
Login and permissions
To ensure existing users can still access their own data
File storage
To check whether stored files would be lost on restart
Timers and background work
To decide whether each piece of work moves to scheduled jobs (runs at set times) or background tasks (continues after a request)
You can keep using external services that the app can connect to after deployment. In that case, you do not need to move the data. For example, an app using an external database should set db.mode: none and supply connection details.
Pass server-side API keys and passwords through secrets. Configure service URLs and public keys used by the browser according to the service’s instructions. Do not embed secret values in code sent to the browser.
If the browser calls a service such as Supabase directly, that service’s access controls still apply. Protecting the app with Keelson login does not automatically replace permissions on external data.
Opening a Keelson app requires Keelson login. If you keep the original login, users will need to sign in to both.
Whether you can remove the original login depends on how the app uses it to identify users and control data access.
Role of the original login
What to do
Only restricts who can open the app
Consider replacing it with Keelson login and app permissions
Restricts which data each user can see
Keep the original authentication, or change authorization to use Keelson user information
Stores the original service’s user IDs in data
Decide how to map existing users to Keelson users
For example, if Supabase row-level security (RLS) uses auth.uid(), removing login alone breaks the ability to identify whose data is being accessed. Check how data access works before deleting the login screen.
Build static sites locally before deploying. Keelson receives the contents of assets.dir and keelson.yaml; it does not run npm install or npm run build for a static site.
For a hybrid app, build the frontend locally and place the output in assets.dir before deploying. Keelson builds the server-side code, but serves the uploaded files as assets.
Set service URLs and public keys that are read at build time before building. Setting server environment variables after deployment does not update an already-built frontend. Ask your agent to handle those settings together with the build and deployment.
Databases, authentication, file storage, and server-side functions provided by the original tool do not move when you deploy source code. Keep connections to services accessible from outside the tool. Otherwise, you will need to migrate data or rewrite the affected functionality.
The original tool’s editing interface does not move to Keelson either. After deployment, change the code with your local AI agent or other tools, then redeploy.
keelson.yaml tells Keelson how to start your app, whether it needs a database, and which tasks to run on a schedule. Place it in the root folder of the app you deploy (the project root).
Your AI agent normally creates and updates this file when you ask it to deploy. This page explains the settings worth reviewing. For all fields and their detailed requirements, see the keelson.yaml reference.
command: "python app.py"# Command that starts the web server
db:
mode: libsql# Use Keelson Managed SQLite
Field
What to check
slug
The name that identifies the app. It forms part of https://<workspace>--<slug>.keelson.run
runtime
The runtime for your app’s language: Python, Node.js, or Go
command
The command that starts the app’s web server. Dependencies are installed during the build
db.mode
Use libsql for a Keelson database, or none if you do not need one or use an external database
db.mode: libsql provisions a database. Your app also needs code to connect to it and read and write data. This setting alone does not switch an existing SQLite file to Managed SQLite.
An app that only uses the Files / Media SDK can use db.mode: none. No configuration is needed to declare Files or Media usage.
Every app needs slug, runtime, and db.mode. The other fields depend on its structure.
App structure
Main settings
Web app
Set command to the startup command
Static site / SPA
Set type: web and assets; omit command
Static frontend + API server (hybrid)
Set both assets and command
Scheduled jobs only
Set crons; omit the web server command
You can add crons to a web app with server-side processing. In that case, do not set type: web: type: web and crons cannot be combined. See Scheduled jobs.
This example serves prebuilt files. Set assets.dir to the build output folder.
slug: my-site
type: web
runtime: node-slim
db:
mode: none
assets:
dir: dist
fallback: index.html# Return index.html when an SPA route is opened directly
Build static sites locally before deploying. Keelson does not install dependencies or run a build for them. Omit fallback for a regular static site if missing pages should not fall back to index.html.
This example sends requests under /api to the server and serves prebuilt frontend files for other paths.
For this structure too, build the frontend locally and put its output in assets.dir before deploying. Keelson builds the server-side code, but the files served as assets are the ones you upload.
Check the app name and storage choice. Make sure slug identifies the intended app and db.mode matches the database you want to use.
Check for API keys or passwords written directly in the file. Declare only names and descriptions in secrets; set the values through the console or another supported method.
Check recurring work. Review the crons schedules instead of relying on timers inside the app.
You can ask your agent to check the startup command and dependency files. This command checks the configuration and files to be uploaded without uploading them:
Terminal window
keelsondeploy--check--json
This check alone does not confirm that the app will start or fit within your plan’s limits. After deployment, open the app URL and test it.
Changes to keelson.yaml take effect on the next deployment. Ask your agent to update the configuration and redeploy.
Keelson sets environment variables such as PORT. When reusing an older configuration, remove or replace retired fields. See the keelson.yaml reference for required changes and validation errors.
The Keelson SDK provides libraries for using file storage, user information, background tasks, email sending, and other Keelson features from your app’s code. It is available for Python, Node.js (JavaScript / TypeScript), and Go. Use only the features you need.
You do not need the SDK just to deploy an app. Add it when you want to keep uploaded images, for example, or tailor the app to the signed-in user. When asking an AI agent to implement a feature, tell it your app’s language and what you want to achieve, and share this page or the relevant guide.
Enqueue a command declared under tasks in keelson.yaml to run on a separate instance, and check its status. Use it for work that continues after a request, such as generating PDFs or heavy external API calls
Use Database (Managed SQLite) for customer records, projects, and other data that needs querying or concurrent updates. Connect with a libSQL client for your language, installed separately from the Keelson SDK.
Add dependencies for your app’s language. The core features are shared across all three languages, but function names, arguments, and helper features vary.
Import the feature you need, for example from keelson import files. Available modules are files, media, identity, tasks, and email. Record the dependency in requirements.txt or pyproject.toml, according to your dependency manager.
Import the package you need, such as github.com/keelsonhq/go-sdk/files. The module includes files, media, identity, tasks, and email; use directory to list members and groups.
Use the SDK in server-side code, not in JavaScript running in the browser. Static sites and SPAs have no server, so they cannot use the SDK. To use these features, add an API server that calls the SDK and deploy as a hybrid app.
Keelson provides connection details and credentials through environment variables for each feature. The SDK normally reads them automatically, so you do not need to put tokens or storage locations in your code.
Files / Media: No additional keelson.yaml configuration is needed.
Identity: No setup is needed to read basic user information from headers. To retrieve groups or member information, enable the Directory API and redeploy.
Email: No additional keelson.yaml configuration is needed. The sender is <app slug>@mail.keelson.run by default; on the Team plan, apps can also send from your own domain.
Tasks: Declare the tasks you enqueue under tasks in keelson.yaml and deploy. See Background tasks.
Without Keelson connection settings, Files and Media save to local folders. With keelson dev serve, Identity receives the same user information headers as in production, and you can switch users (without the CLI, KEELSON_LOCAL_MODE=1 makes it return test user information). Tasks runs the command synchronously on your machine through the CLI’s keelson dev task run (see Run locally). Email cannot send from your machine; without Keelson connection settings, sending raises an error. Local files and test user information are not automatically carried over to your deployed app.
Use Managed SQLite to store customer records, projects, requests, and other data your app creates, searches, and updates. Keelson provisions a dedicated database for each app, and saved data survives restarts and redeployments. You can also connect to an external database you already use.
For example, a project tracking app can keep project names and assignees in the database and attached PDFs in Media. Store each attachment’s ID in the database to link the file to its project.
Here, “local files” means files written inside the server running your app on Keelson. They are lost when the app restarts, is redeployed, or stops after a period without traffic. This is called “ephemeral” storage.
Changing the folder to ./data.db or /tmp/data.db does not preserve the data across restarts (/data does not exist: Keelson does not create it and the app cannot create it). Scheduled jobs run in a separate environment from the web app and cannot share these files either.
You can use /tmp for files needed only during processing, such as incoming uploads or intermediate conversions. Save anything you need later in one of the persistent stores above.
Managed SQLite is SQLite-compatible, so the SQL and table definitions of an app built on SQLite usually stay as they are. What changes is the connection: replace the places where sqlite3 or better-sqlite3 write directly to a file inside the server with a libSQL client connection. The client APIs differ: Node.js moves from the synchronous better-sqlite3 to the asynchronous @libsql/client, so add await in each handler. Python’s libsql differs from sqlite3 in row_factory, type adapters, and exception classes. In Python / Node.js, pass the same client a file such as file:local.db and it works as file-based SQLite in local development.
If your app uses sqlite3 or better-sqlite3 to save data that must persist, you can ask your AI agent:
Make this app’s data survive restarts and redeployments on Keelson. Find any file-based SQLite usage, and explain the changes needed for Managed SQLite and how to move existing data.
Deployment checks reject detected SQLite file usage unless it is declared. For regenerable caches only, you can declare temporary SQLite use with db.local_sqlite. This does not make the files survive restarts. See the reference.
Add this setting to keelson.yaml to provision a dedicated database for the app. Your AI agent normally writes both the configuration and connection code.
db:
mode: libsql
No database server setup is needed. Keelson supplies the connection URL and authentication token through the app’s environment variables.
Each app’s data is separate. Its credentials can access only its own database.
Committed data survives restarts. Data is saved when the SQL commit completes.
The database is hosted in the same region as the app. Japan is currently the only available region (region). US West is in preparation.
You can restore from backups. Daily backups, manual backups, and point-in-time recovery are supported.
In addition to changing the configuration, your app must connect through a libSQL client. SQL works as in SQLite. On Keelson the connection uses the URL and authentication token supplied by Keelson instead of a local file path. In Python / Node.js, fall back to file:local.db when the environment variable is absent, and the same code runs as file-based SQLite in local development (the Go libSQL client cannot open file:, so this pattern does not apply to Go). Compatible clients are available for Python, Node.js, and Go.
These examples create a table and save one record. Add libsql to your Python dependencies or @libsql/client to your Node.js dependencies.
A database’s “schema” defines its structure: table and column names, data types, and constraints. If a table does not exist yet, create it with CREATE TABLE IF NOT EXISTS, as in the examples above.
To change tables or columns during deployment, set a command in db.migrate. This process is called a “migration.”
db:
mode: libsql
migrate: "python migrate.py"
db.migrate runs before switching to the new code. If it fails, the app continues serving with the previous code. Any database changes already made are not automatically undone.
Make the command safe to repeat without duplicating tables or data (idempotent). The updated database must remain readable and writable by the previous code. Static sites without command do not run db.migrate. See Update and restore.
SQLite processes writes one at a time. Simultaneous writes from a web app and scheduled jobs, for example, may have to wait. If concentrated writes affect response times, review the workload or consider an external database such as PostgreSQL.
To connect to existing PostgreSQL, MySQL, or external libSQL, set db.mode: none. Provide the connection details yourself and set them as secrets. This setting does not create an external database or move existing data.
db:
mode: none
secrets:
items:
- name: DATABASE_URL
description: "PostgreSQL connection string"
required:
- all_of: [DATABASE_URL]
Django deploys only through this external-database route (Managed SQLite has no backend for the Django ORM). See Framework notes.
Common reasons to use an external database include:
Situation
Option
Data volume or performance needs exceed what Managed SQLite can handle
External PostgreSQL
Very high concurrent write volume
PostgreSQL or MySQL
Direct access from BI tools or core business systems
Shared PostgreSQL or a data warehouse
Backup retention longer than your Keelson plan provides
Open Restore points on the app’s console page and select a backup or time on the timeline to restore or download it.
Recovery applies to one app and does not affect others. The state before recovery is kept for 72 hours, during which you can undo the recovery. Performing a recovery requires approval in the browser.
These backups restore only Managed SQLite data. They do not include Files / Media SDK files, external databases, app code, or configuration.
For example, restoring an attachment ID in the database does not bring back a file already deleted from Media. See Update and restore for reverting changes that include code and configuration.
Managed SQLite has no fixed per-plan capacity limit, but underlying technical limits still apply. App execution may be restricted or suspended if usage significantly exceeds normal business workloads or affects platform stability, other customers, or reasonable operating costs. In emergencies, restrictions may be applied without prior notice.
Workspace storage has a limit for each plan. It includes Files / Media files, snapshots, deployment files, and similar data. Database capacity is separate.
Use the Files SDK or Media SDK to save files that need to survive app restarts and redeployments. For an overview, see Keelson SDK.
Use Files for files your app reads and writes internally, and Media for files displayed in the app or opened by users. Media provides URLs that browsers can open directly, making it suitable for images and attachments.
Server-side code reads through the SDK. No serving URL is provided
Server-side code can read files, and browsers can open them by URL
Updates
Overwrite the same name (key). Aim for roughly one update per key per second
Each newly saved file gets an ID; existing files cannot be overwritten
Maximum file size
10 MiB
50 MiB
Choose based on how a file is used, not just its format. A CSV used internally for calculations fits Files; a CSV report delivered to users fits Media.
Media URLs are not public links that anyone can open. Only members who can view the app can open them after signing in to Keelson. Neither Files nor Media requires additional keelson.yaml configuration.
Media access is controlled at the app level. It does not automatically enforce per-file permissions, such as allowing users to see only their own attachments. For those cases, consider storing files in Files and having the app check permissions before returning them.
Data written to local files while the app runs does not survive restarts or redeployments. Writing to /tmp or the app directory alone does not preserve files for later use.
Once a Files / Media SDK save completes, the file survives app restarts. No later synchronization step is needed. Use Managed SQLite for customer records, projects, and other data you search and update by field.
Follow Keelson SDK installation to add Files or Media to your app’s dependencies. The examples below run on the server. You can usually ask your AI agent to implement storage by explaining what files you want to save and how they will be used.
Files works well for files that your app saves under a name and updates under that same name. Saved files can be shared between the web app and its scheduled jobs.
The storage name is called a “key,” such as settings.json or reports/2026-08.csv. The main Node.js / Python operations are write to save, read to read, delete to remove, and list to list keys. If a file does not exist, read returns null in Node.js or None in Python.
This example saves and reads settings, lists reports, and deletes an old report.
When moving from local files, change both reads and writes to use the SDK. Files does not provide serving URLs. To deliver a saved file to users, implement a download handler in your app or use Media.
If multiple processes read and update the same key, one update may overwrite another. Use database transactions for processing status, counters, or other data that needs concurrent updates.
Media is suitable for images displayed in the app and PDFs or attachments users open. Keelson serves saved files through URLs, so your app does not need its own file-serving handler.
Saved files cannot be overwritten. To replace a file, save a new one and update the ID in the database. Delete the old file separately when it is no longer needed.
Use get(id) to read contents on the server, and stat(id) to check the Content-Type and size.
Use exists(id) to check existence. Delete with del(id) in Node.js or delete(id) in Python.
If you omit Content-Type, it is inferred from the filename extension. If it cannot be inferred, it defaults to application/octet-stream.
Without Keelson connection settings, both SDKs save files in local folders. These local files are not automatically transferred to Keelson Files / Media when you deploy.
Files writes actual files under ./.keelson/files/. Add .keelson/ to .gitignore.
Media writes under ./media/ (configurable with MEDIA_DIR).
On Keelson, the platform sets the required environment variables.
In Media local mode, the default URL is /media/<file-id>. To preview files in a browser, add a handler to your development server that serves them. media.url() returns a URL; it does not start a local HTTP server.
The Files and Media SDKs support local mode on Linux, macOS, and Windows for Node.js, Python, and Go.
The Files SDK maps keys directly to local paths. On Windows and macOS filesystems configured as case-insensitive, keys that differ only by case, such as Report and report, cannot coexist. Path-based backends also have a race if another process replaces a checked component with a symlink or junction before the operation completes. Use them for trusted, single-user local development. The production GCS path does not have this limitation.
The console and CLI do not provide file listing, downloading, or replacement for app files. Build a file list in your app for users. For Media, create links from saved IDs; for Files, implement a download handler when needed.
Store files that exceed the size limits directly in external object storage, such as S3-compatible storage. Outbound connections are unrestricted, except for the SMTP ports.
Files / Media files count toward workspace storage capacity (Plans and limits).
Managed SQLite backups do not include Files / Media file contents. Restoring a database does not restore deleted files.
Run tasks at set times or intervals, such as a report every morning or data synchronization every hour. These are called “scheduled jobs.” They run even when nobody is using the web app.
Tell your AI agent what should run and when, and it can configure crons in keelson.yaml.
Add a scheduled job that summarizes the previous day’s sales at 9 a.m. every morning in the workspace’s timezone. Make the results available in the logs.
This example adds a job to a web app that runs report.py every day at 9 a.m.
slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql
crons:
- name: daily-report
description: "Email yesterday's sales report"
schedule: "0 9 * * *"# Every day at 09:00
command: "python report.py"
timeout: 120# Limit each run to 120 seconds
Field
Meaning
name
Job name, using lowercase letters, numbers, and hyphens
schedule
Cron expression specifying the time or interval
command
Command that performs one run of the task and exits
timeout
Maximum duration per run in seconds. The job stops when this limit is reached
enabled
Set to false to stop scheduled runs. Defaults to true
description
Optional. A one-line explanation of what the job does, shown in the Description column of the console’s Scheduled jobs tab. Leading and trailing whitespace is trimmed, and an empty value counts as unset. Up to 100 characters. It is display only and does not change what runs
name, schedule, and command are required. Set timeout between 1 and 600 seconds, within your plan’s limit. If omitted, it defaults to 300 seconds or your plan’s limit, whichever is shorter. Explicitly setting a value above the limit causes deployment to be rejected.
You can also create an app containing only scheduled jobs. Omit the top-level command in that case. Apps with only scheduled jobs do not count toward the concurrent app limit.
schedule uses a five-field cron expression to specify times or intervals.
Schedule
Cron expression
Every day at 09:00
0 9 * * *
Weekdays at 09:00
0 9 * * 1-5
Every hour, on the hour
0 * * * *
Every 15 minutes (Plus / Team)
*/15 * * * *
Every 5 minutes (Team)
*/5 * * * *
Midnight on the first day of each month
0 0 1 * *
Schedules are evaluated in the workspace’s timezone. It is set from the browser when the workspace is created and cannot be changed later. A run may start later than its scheduled time.
The minimum interval is 60 minutes on Starter, 15 minutes on Plus, and 5 minutes on Team. Shorter schedules are rejected during deployment. Running every minute is not supported.
Cron expression fields
┌───────────── Minute (0–59)
│ ┌─────────── Hour (0–23)
│ │ ┌───────── Day of month (1–31)
│ │ │ ┌─────── Month (1–12)
│ │ │ │ ┌───── Day of week (0–6, 0 = Sunday)
* * * * *
* means every value. For example, 0 9 * * * means 09:00 on every day of every month, regardless of the day of the week.
On the app’s console page, you can see each job’s run history, success or failure, and logs. Use Run now to test a job without waiting for the next scheduled time.
If a run fails, check its logs, required secrets and configuration, and whether it can finish within timeout. You can share the logs with your AI agent and ask it to investigate and fix the problem.
Standard output and standard error are each recorded up to 256 KiB per run. Output beyond that is truncated.
Only runs that actually start are counted. Successful runs, failed runs, and Run now executions count; skipped runs do not. Total runtime is elapsed time, including time spent waiting for external APIs. If a run cannot be measured because of a platform failure, it consumes one run but does not count toward total runtime. Once either monthly limit is reached, remaining runs that month are skipped. A run that has already started is allowed to finish, and the allowances reset the following month.
The maximum number of jobs at the minimum interval does not fit within the monthly limits. The job count, minimum interval, per-run duration, monthly run count, and total runtime are independent limits.
A scheduled job performs one unit of work and exits. It is not a way to keep an app running continuously.
Situation
Behavior
Relationship to the web app
Runs in a separate environment; memory and local files are not shared
Saving data
Files inside the execution environment do not carry over to the next run. Save persistent data in Managed SQLite or with the Files / Media SDK
Previous run is still running
The next run of the same job is skipped. It is not queued for later
Run fails
No immediate automatic retry. The next scheduled run proceeds as usual
Time limit is reached
The job stops, even if it is partway through its work
Timers and schedulers inside an app do not run while the app is stopped. Use crons for work that must run on a schedule. Background work after returning a response is also not guaranteed to finish. Complete it during the request, or enqueue work that continues after the response as a background task.
Prevent duplicate records or deliveries when an interrupted task is retried or someone uses Run now to repeat it. This property is called “idempotency.”
For example, if reports.date has a primary key or UNIQUE constraint, you can update an existing record for the same date.
conn.execute("""
INSERT INTO reports (date, total) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET total = excluded.total
""", (today, total))
conn.commit()
Process accepted work later without background tasks
To run accepted work right after the request, use background tasks first. If you build it without background tasks, or a wait until the next run is acceptable, use the method below.
To collect work in the web app and process it later, save pending work in Managed SQLite. A scheduled job reads the pending records, processes them, and records completion.
# Run every 5 minutes on the Team plan
crons:
- name: process-pending
schedule: "*/5 * * * *"
command: "python process_pending.py"
timeout: 120
Allow for the delay before work starts. Work waits until the next run, so this is unsuitable for tasks that must start immediately. Tell users about the wait.
Plan for repeated processing. An action such as sending email may succeed, but the job may stop before recording completion in the database.
Record failure counts and reasons. Set a retry limit and decide what to do with repeatedly failing records. Keelson does not automatically retry or isolate individual pending records.
Complete short tasks within the request before returning the response.
When processing many records, cap the number handled in each run and leave the rest for the next one. Split work that exceeds the execution time limit across multiple runs.
When calling external APIs, also respect that service’s rate limits. Increase the interval or reduce the number of calls per run.
This example assumes sales and reports tables already exist and reports.date has a primary key or UNIQUE constraint. Add libsql to your dependencies.
The example summarizes the previous day in Japan time. It assumes sales.date stores dates in YYYY-MM-DD format using the same timezone. Change Asia/Tokyo to the timezone used for your reports. The workspace schedule timezone does not automatically change the timezone your code uses to calculate dates.
Add @libsql/client to your dependencies and set the SYNC_API_URL environment variable. This example assumes a synced_items table exists with a primary key or UNIQUE constraint on id, and that the API returns an array of objects with an id.
sync.js
import { createClient } from"@libsql/client";
constdb=createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
constres=awaitfetch(process.env.SYNC_API_URL);
if (!res.ok) thrownewError(`Sync API error: ${res.status}`);
constitems=await res.json();
for (constitemof items) {
await db.execute({
sql: `INSERT INTO synced_items (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
Run slow work that follows a request, such as generating a PDF or making a heavy external API call, on an instance separate from the web app. This is called a “background task” (or “task” on this page).
Declare a command under tasks in keelson.yaml, then enqueue it from your app’s code with the Keelson SDKenqueue function. Keelson starts the command once and retries it automatically if it fails.
Tell your AI agent what should continue after the request, and it can write the tasks declaration and the code that enqueues it.
When an order is placed, generate a receipt PDF and email it as a background task. Make sure running the same task twice does not create duplicate PDFs or emails.
Work that a request starts and that runs afterward
Background tasks (tasks)
Tasks suit work such as:
Work that does not fit within the HTTP request limit (120 seconds)
Heavy external API calls (AI generation, sending large amounts of data, and so on)
Generating PDFs or images, or sending bulk email
Work that continues inside the app after the response is sent (such as FastAPI’s BackgroundTasks) is not guaranteed to finish. Move that work to a task.
Write the task name and the command to run under tasks in keelson.yaml.
slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql
tasks:
- name: generate-pdf
description: "Generate the invoice PDF for an order"
command: "python generate_pdf.py"
timeout: 180# limit each attempt to 180 seconds (valid on every plan)
max_attempts: 3# try up to 3 times, including the first attempt
Field
Meaning
name
Task name, using lowercase letters, digits, and hyphens. You pass this name when enqueueing
command
Command that performs one run of the work and exits
timeout
Time limit for one attempt, in seconds. Defaults to 300 seconds (or your plan’s limit, if lower). A value above your plan’s limit is rejected at deploy
max_attempts
Maximum number of attempts, including the first. Defaults to 3
description
Optional. A one-line explanation of what the task does, shown in the console’s Background tasks tab. Leading and trailing whitespace is trimmed, and an empty value counts as unset. A value longer than 100 characters is rejected at deploy (task_description_too_long; it is not truncated). It is display only and does not change what runs
name and command are required. See the keelson.yaml reference for allowed ranges and validation rules.
From your app’s code, enqueue a task by its declared name with input data. The call returns right away with the task ID (task_id). See Keelson SDK for installation.
if errors.As(err, &te) && te.Code =="TASK_MONTHLY_QUOTA_EXCEEDED" {
// this month's limit is used up
}
return err
}
status, err := client.Get(ctx, taskID)
When you pass an idempotency_key, enqueueing again with the same task name and key does not create a new task; it returns the first task’s ID. A key is 1–128 printable ASCII characters.
get returns one of five statuses (status):
Status
Meaning
queued
Waiting. Includes waiting to start and waiting to retry
claimed_attempts is the number of attempts started, and last_failure_code is the reason the last failed attempt failed.
To show completion in your UI, have the task command write its result to the database and have the UI read a status column there. Use get as a supplementary check.
# An uncaught exception exits non-zero and counts as a failure
import { readFileSync } from"node:fs";
constdoc=JSON.parse(readFileSync(0, "utf8"));
constorderId= doc.payload.order_id;
// ... generate and save the PDF ...
// An uncaught exception exits non-zero and counts as a failure
var doc struct {
AttemptNo int`json:"attempt_no"`
Payload json.RawMessage`json:"payload"`
SchemaVersion int`json:"schema_version"`
TaskID string`json:"task_id"`
TaskName string`json:"task_name"`
}
if err := json.NewDecoder(os.Stdin).Decode(&doc); err !=nil {
log.Fatal(err) // exits with code 1 and counts as a failure
}
// ... generate and save the PDF ...
Tasks do not share a local disk with the web app. Save results to the database or with the Files / Media SDK. The command can read the same environment variables as a scheduled job.
Tasks run at least once. Failed attempts are retried automatically, and even after a successful attempt, the command can run again if its completion does not reach Keelson. Writing the command so that running it more than once does not corrupt the result (making it idempotent) is your app’s responsibility.
Follow these three rules:
Compare state before changing it: do “mark as processing if still pending” in a single UPDATE. If no rows were updated, another attempt is already handling it, so exit.
UPDATE orders SET pdf_status ='processing'
WHERE id = ? AND pdf_status ='pending';
Pass an idempotency key to external APIs: for external APIs where receiving the same request twice is a problem, such as payments or email, pass an idempotency key. The task_id works, because it stays the same across retries.
Use fixed output file names and row keys: save under names derived from the input, such as receipts/order-1.pdf. Names that include timestamps or random values produce two outputs when the command runs twice.
If you want “every saved order also enqueues a receipt task,” you cannot put saving the order and enqueueing the task in one transaction: the order is stored in your app’s database, while the task is recorded on Keelson. If the app stops right after saving, the order remains but the task is never enqueued.
To prevent this, write a row for “a task not yet enqueued” (an outbox row) to your app’s database in the same transaction as the order, then enqueue after the commit. This is commonly called the outbox pattern.
In one transaction, write the order row and an outbox row (ID, task name, input, and an enqueued flag)
After the commit, call enqueue with the outbox row’s ID as the idempotency_key
When the enqueue succeeds, mark the outbox row as enqueued
Rows whose enqueue failed, or whose app stopped before marking them, are sent again with the same key on the next request or by a scheduled job. Because the key is the same, the task is never enqueued twice
Web (request handling) App database Keelson (tasks)
│ ① Write the order row + outbox row in one transaction
│ ─────────────────────────────▶ │
│ ② enqueue(name, input, idempotency_key = outbox row ID)
Automatic retries: when the command ran and failed (exit_nonzero, timed_out, or no_completion) and the attempt count has not reached max_attempts, Keelson retries after waiting 10, 20, 40, and then 80 seconds. You cannot change these intervals.
When the task ends before the command runs (delivery_failed, manifest_unresolvable, or quota_exhausted), it is not retried and becomes failed immediately.
last_failure_code
Meaning
exit_nonzero
The command exited with a non-zero exit code
timed_out
The command exceeded its time limit (timeout) and was stopped
no_completion
The deadline passed without a completion report
delivery_failed
The attempt could not start within one hour of becoming ready to run
manifest_unresolvable
Two or more deploys happened while the attempt was waiting, so the declaration it needed could not be resolved
quota_exhausted
The monthly limit could not be reserved for a retry, or for an attempt starting in a new month
Investigate: on the app’s Background Tasks tab in the console, you can see each task’s status and the standard error of its last attempt (up to 256 KiB, with secret values masked). get in the SDK does not return standard error.
Try again: there is no manual re-run. To try again, enqueue the task again from your app. If you use an idempotency key, use a different key from the original task (the same key returns the original task).
When tasks are cancelled: the following actions change the affected queued tasks to cancelled. An attempt that is already running finishes, but it is not retried if it fails.
Manually suspending the app
Quarantining the app for abuse
Deleting the app (task records are deleted too)
A deploy that removes the name from tasks (only tasks with the removed name)
You can also cancel a specific queued task, one at a time, from the console’s Background Tasks tab. An app sleeping because it has no traffic does not cancel tasks.
1,000 per app. Beyond that, enqueueing is rejected with TASK_BACKLOG_LIMIT_EXCEEDED
Run time of one attempt
300 seconds by default, 600 seconds maximum. Plan limits: Starter 180 seconds / Plus 300 seconds / Team and above 600 seconds
Attempts
3 by default, 5 maximum
Input size
65,536 bytes for the whole enqueue request (payload and idempotency_key as JSON). Larger requests fail with TASK_PAYLOAD_TOO_LARGE
Input retention
Deleted 7 days after the task finishes
Monthly limit
The run count and total run time of “Background Jobs,” shared with scheduled jobs
If the default timeout (300 seconds) exceeds your plan’s limit, it is lowered to the limit. Explicitly setting a value above the limit causes the deploy to be rejected.
For the monthly limit, each started attempt counts as one run, including retries. Once the limit is reached, new tasks are rejected with TASK_MONTHLY_QUOTA_EXCEEDED. Enqueueing again with the same idempotency key still returns the first task, even at the limit.
Each plan limits how many tasks run at the same time; tasks beyond that wait their turn. See Plans and limits for the monthly limit values.
You can enqueue tasks not only from web request handling but also from scheduled jobs and from task commands. For example, a nightly scheduled job can find the records to process and enqueue one task per record.
Keelson handles authentication automatically. You do not need to put tokens in your code.
Outside Keelson (when KEELSON_MODE=local is set, or when no Keelson environment variables are present), the SDK’s enqueue starts the CLI’s keelson dev task run as a child process and returns after the command finishes (synchronous execution).
Local runs differ from production as follows:
enqueue does not return until the work finishes
The command runs as a child process on the same development machine, not on a separate instance. It can see local files that it would not see in production
There are no retries. If the command fails, enqueue does not raise an error; get returns failed
There is no concurrency limit, unprocessed task limit, or monthly limit
Run time is not lowered to a plan limit; the declared timeout is used as is
An idempotency key prevents duplicates only for sequential enqueues within the same process. It does not prevent them for concurrent enqueues, across processes, or after a restart
get finds only tasks enqueued within the same process
Passing locally does not guarantee the same result in production, where retries and plan limits apply and an attempt can run more than once.
Running locally requires a CLI version that includes keelson dev task run. With an older CLI, enqueue fails with TASKS_LOCAL_CLI_FAILED; update with keelson upgrade.
You can also test just the command without going through the app:
Keelson handles login and checks who can open your app. Your app can use the resulting user information to record who created a record or show users only their own data.
Implement business rules, such as who can approve a request, in your app. Choose among these three approaches:
What you want to do
What to use
Additional setup
Identify the person accessing the app, record authors, or show personal data
User information headers such as X-Keelson-User-Id
None
Show a screen only to app operators who can deploy or change settings
X-Keelson-User-App-Perms header
None; assign permissions in the console
Vary behavior by department or job function, or identify approvers
Use group memberships or your app’s own data, such as an assignee table, for business permissions like “only a manager can approve.” The manage permission is for deploying and configuring an app; its holders are not necessarily business approvers.
For requests from signed-in users, Keelson provides user information in headers (metadata attached to the request). Read these headers in your server-side code.
Header
Content
X-Keelson-User-Id
User ID: a stable identifier to store in your database
X-Keelson-User-Email
Email address, or an empty string if Keelson has no value
X-Keelson-User-Name
Display name, or an empty string if Keelson has no value
To record the author, save the user ID alongside the data. To show only the user’s own data, include that ID in your database query. Reading the header alone does not filter data automatically.
Keelson adds this information after verifying the user’s login. If a caller sends headers with the same names, Keelson replaces them with the verified user’s information. Your app does not need to manage the Keelson login session.
The Identity SDK returns user information as id, email, and name. See Keelson SDK installation. Run the following examples on the server.
getCurrentUser only reads headers. It makes no external request and does not require the Directory API to be enabled. In JavaScript, call it with await. A missing or empty user ID header raises IdentityError.
To also get the user’s permissions for this app (X-Keelson-User-App-Perms), use getRequestUser (get_request_user in Python, GetRequestUser in Go). It returns the same values as getCurrentUser plus perms (usage).
Apps have two permissions, view and manage, assigned to groups through the console or CLI (Groups and app access). manage allows operators to deploy, change configuration, and edit secrets. Owner, Admin, and Developer have it by default. Keelson passes the resulting permissions in a header.
X-Keelson-User-App-Perms
Meaning
view
Can view the app
view,manage
Can view and manage the app
Your app only needs to check whether manage is present. Do not hard-code group names for this check.
// Express: read inside the handler because headers vary by request.
To make an app available only to the accounting team, assign permissions; no app code is needed (Share an app with your team). To let only operators open a settings screen, check manage. You can change who has manage in the console without changing code.
Use the Identity SDK to get group memberships when you need department-specific behavior or business permissions such as allowing only approvers to approve.
Hiding controls in the UI is not enough. When implementing “users can see only their own requests,” filter lists and also enforce on the server that users cannot retrieve or update someone else’s request by specifying its ID in the API. Base decisions on Keelson-provided headers or SDK results, not values supplied by the browser.
To retrieve group or member information, enable read access to the Directory API, then redeploy. Enabling it alone does not make it available to the running app.
Terminal window
keelsonappsdirectoryenable
keelsondeploy
After redeployment, the app receives KEELSON_DIRECTORY_TOKEN, which the SDK uses automatically. Keep this token on the server; do not send it to the browser.
// Use this result to decide whether to allow accounting operations.
from keelson import identity
me = identity.get_current_identity(headers=request.headers)
groups = (me.attributes.groups or []) if me.attributes else []
is_accounting_member ="accounting"in groups
# Use this result to decide whether to allow accounting operations.
attributes.groups contains group keys:
Role-based system groups: Owner receives owners, developers, and everyone; Admin receives admins, developers, and everyone; Developer receives developers and everyone; App User receives everyone.
Custom groups the user belongs to that have permissions assigned for this app. Groups not assigned to the app are omitted.
Keys are stable and can be used in code. Japanese keys, such as 経理 (accounting), are also supported.
getCurrentIdentity queries the Directory API. Its result also includes user information (user.id, user.email, user.name), the workspace role (workspace.role), and app permissions (app.permissions). User information is nested differently from the result of getCurrentUser.
When you need information about other members, such as options in an assignee picker, use listMembers / list_members (with search and role or group filters), getUser, or listGroups.
Each member has id, email, name, role, and image_url. image_url is the profile image URL (served from img.clerk.com), or null if the member has no image. Append width / height query parameters to get a smaller image.
On the server, return only what the picker needs:
import { listMembers } from"@keelsonhq/identity";
// Inside a request handler. Returns up to 100 members per call.
Store only the member id in your app’s database, for example in a task’s assignee column. Do not store image_url, name, or email. Look up the latest values with listMembers or getUser each time you display them; a member’s image can change at any time, and a stored copy becomes stale.
When you run your app on your machine, there is no Keelson login, so requests carry no user information headers. Start it with keelson dev serve to receive the same headers as in production and switch between a view-only user and a user with manage permission. When you start it with KEELSON_LOCAL_MODE=1 and no CLI, the Identity SDK returns one fixed user. See Run your app locally.
Custom groups cannot be reproduced locally. Also test group-based behavior in a test app with real permissions configured.
Requests to /api/external/ or /api/webhooks/ authenticated with an app token do not carry a signed-in user’s ID. Handle them separately from code that calls getCurrentUser or getCurrentIdentity. See External integrations for authentication details.
When you run your app on your machine, there is no Keelson login, so requests carry no user information headers such as X-Keelson-User-Id. With keelson dev serve, your app receives the same headers as on Keelson, and you can switch between users to check its behavior.
Returned from the user list (no need to enable the Directory API)
Returned from the user list
Requires
The latest Keelson CLI
The latest SDK
Use keelson dev serve normally. If the CLI is not installed, or only an older CLI is available, start with KEELSON_LOCAL_MODE=1. Your app code is the same either way.
The CLI picks a free port for your app and passes it in the PORT environment variable. It also replaces {port} in the command with that number. Your app must listen on that port on 127.0.0.1
Open http://localhost:5173/ in your browser. Change the port with --port
The CLI sits in front of your app and adds the selected user’s headers (X-Keelson-User-Id / X-Keelson-User-Email / X-Keelson-User-Name / X-Keelson-User-App-Perms) to every request. As in production, the browser’s cookies and Authorization header do not reach your app
On startup, the CLI prints the app URL, the current user, and how to switch users. Press Ctrl-C to stop it; your app’s server stops too
User ID. Must be unique within the list. Any readable string works; it does not need to match production IDs
email / name
Email address and display name. Empty strings are allowed
perms
["view"] (view only) or ["view", "manage"] (with manage permission). Becomes the value of X-Keelson-User-App-Perms
image_url
Optional. Profile image URL
The first user in the list with manage is the default user
If the specified file is missing or invalid, startup fails with an error
Without a user list file, four built-in users (local-user-001 to local-user-004) are used. With keelson dev serve, local-user-003 and local-user-004 have view permission only
Start your app’s server directly with the KEELSON_LOCAL_MODE=1 environment variable.
Terminal window
KEELSON_LOCAL_MODE=1nodeserver.js
If your app uses its own user list with keelson dev serve --users <file>, also pass KEELSON_LOCAL_USERS_FILE=<same file> when you start it directly. User IDs then stay the same with or without the CLI.
The Identity SDK does not read headers and returns one fixed user (the first user in the list with manage) as the current user. Without a user list file, it returns a test user with manage permission (local-user-001). You cannot switch users. This test data does not apply to code that reads headers directly.
Get the current user and permissions (getRequestUser)
getRequestUser returns the current user and their permissions for this app (perms) from the request headers in one call. It makes no external request. The same code works in production and in both local modes above.
A missing user ID header raises an error. perms is X-Keelson-User-App-Perms split into a list, or empty when the header is absent. Display names in non-ASCII scripts, such as Japanese, are read correctly.
You can make your app an MCP server (use your app from AI clients). MCP (Model Context Protocol) is a protocol that lets AI clients such as Claude and ChatGPT list and call external “tools”. For example, if you give an inventory app a “search items” tool and a “record an arrival” tool, users can operate the app from a conversation with their AI client, within their own permissions.
Your app only provides the tool declarations in the mcp: section of keelson.yaml and one JSON endpoint (handler) per tool. Keelson handles login, the consent screen, and the MCP exchange with the AI client.
The app author declares tools in the mcp: section of keelson.yaml and deploys
A user registers the connection URL (the app URL + /mcp) in their AI client
The first time, the Keelson login and consent screens appear. When the user approves, the AI client is connected as that user
When the AI client calls a tool, Keelson checks membership, app permissions, and IP restrictions, and validates the arguments against the declaration
Keelson calls POST /api/mcp/<tool name> on your app and returns the JSON your app sent back to the AI client as the tool result
Only apps that run server-side code (apps with a command and no assets) can use this. Static sites, SPAs, and hybrid apps cannot declare it. It is available on every plan, with no differences between plans.
With mcp.file, you can put instructions and tools in a separate YAML file. keelson.yaml then contains only enabled and file.
slug: stock-desk
runtime: python-slim
command: "python app.py"
db:
mode: libsql
mcp:
enabled: true
file: mcp.yaml
mcp.yaml in the same folder as keelson.yaml:
instructions: "Look up stock levels and record incoming deliveries."
tools:
- name: search_items
description: "Search stock by item name."
access: read
input:
type: object
properties:
query:
type: string
required: [query]
- name: create_arrival
description: "Record one incoming delivery."
access: write
permission: manage
input:
type: object
properties:
item_id:
type: string
quantity:
type: integer
minimum: 1
required: [item_id, quantity]
When you deploy, the Keelson CLI inserts the contents of mcp.yaml into keelson.yaml before validating and uploading it. Your local files are not rewritten
Only instructions and tools can go in the separate file. mcp.file cannot be combined with mcp.instructions / mcp.tools
Only newer versions of the Keelson CLI read mcp.file. Older versions reject the deploy because of an unsupported key. If that happens, run keelson upgrade to update the CLI to the latest version
When a tool is called, Keelson sends POST /api/mcp/<tool name> to your app. The path is determined by the tool name and cannot be changed.
The body is the JSON object of arguments the AI client passed ({} when there are none). Only arguments that passed validation against the declared input reach your app.
Only headers that Keelson builds reach your app. No header sent by the AI client is passed through, so the AI client’s access token and forged X-Keelson-* headers never reach your app.
Header
Content
X-Keelson-User-Id
User ID of the connected user. Use this to identify the user
X-Keelson-User-Email
The same user’s email address
X-Keelson-User-Name
The same user’s display name
X-Keelson-User-App-Perms
The user’s permissions on the app (view or view,manage)
X-Keelson-Auth-Source
mcp (indicates a call through MCP)
X-Keelson-Mcp-Tool
Name of the called tool
X-Keelson-Mcp-Client
Identifier (URL) of the AI client
X-Request-Id
Request ID. Also shown as request id: in error results
The Authorization header carries a value used inside Keelson. It is not the AI client’s token, and your app does not use it. Use the user information headers the same way as in Use signed-in user information.
Return a response of 2xx with JSON (up to 1 MiB). Returning a JSON object makes the fields easier for the AI client to read.
Your app’s response
Result returned to the AI client
2xx with a JSON body
The JSON body as the tool result
2xx with an empty body, a non-JSON body, or a body over 1 MiB
Error (invalid tool response)
4xx
Error. The first 4 KiB of the body is shown as is to the user and the AI client
3xx or 5xx
Error (tool execution failed). The body is not shown
No response within 60 seconds
Error (unknown whether it ran)
The body of a 4xx response is visible to the user and the AI client. Write only explanations that may be shown (for example, “Item A-100 was not found”)
Each call has 60 seconds in total. Enqueue slow work as a background task and respond right away
Keelson does not pass calls to a permission: manage tool from people without manage permission. Checking X-Keelson-User-App-Perms in your app as well, as in the examples above, keeps the tool safe if the declaration changes.
Tools with access: write are shown separately on the consent screen as “Write tools (can change data)”. Users can choose “Approve read tools only”. In that case, write tools cannot be used. To use them later, the user connects again and approves them
Tools with permission: manage can be used only by people with manage permission on the app. For assigning permissions, see Members and roles
Tools a user cannot use do not appear in the list. Tools that need permissions the user lacks, and write tools the user did not approve, are removed from the AI client’s tool list
Every call is made as the connected user, within that user’s permissions. Calls from people removed from the workspace or who lost view permission on the app are usually rejected within 2 minutes
Users register the connection URL in their AI client. The connection URL is the app URL followed by /mcp (for example, https://acme--stock-desk.keelson.run/mcp), and it is also shown on the app’s MCP tab in the console. For how to register the connection URL, see the MCP settings documentation of each AI client.
Register the connection URL in the AI client
The first time, the Keelson login screen opens. Log in with an account that can use the app
On the consent screen, check the tools the AI client will use and select Approve
Only members who can view the app can connect
Only AI clients that identify themselves as claude.ai or chatgpt.com are accepted. Other clients stop at the login step even if the connection URL is registered
Access tokens are valid for 1 hour, and the AI client renews them automatically. The renewal token expires after 60 days without use, or at most 180 days after connecting. When it expires, connect again
Connecting the same AI client to the same app again disconnects the previous connection
Stop MCP for the workspace (Settings → Security → MCP (AI clients))
Owner / Admin
Stops calls to every app in the workspace. Connections remain and work again once MCP is allowed
Deploy with mcp.enabled: false
People who can deploy the app
Stops calls to that app. Connections remain and work again after you set it back to true and deploy
Revoke under “Connected AI clients” on the app’s MCP tab
Owner / Admin, people with manage permission on the app
Disconnects that connection. The AI client immediately loses access, including its renewal token
Revoke under “Your connected AI clients”
The user who connected
Disconnects the user’s own connection
Stopping MCP and mcp.enabled: false only stop calls. Only disconnecting (revoking) ends a connection. “Your connected AI clients” is linked from Settings → Security and from the consent screen.
For an app with mcp.enabled: true, Keelson responds to /mcp, /.well-known/oauth-protected-resource, and /.well-known/oauth-protected-resource/mcp, and they never reach your app
For apps that run server-side code (including the server side of a hybrid app), paths /api/mcp and paths starting with /api/mcp/ return 404 except through MCP, even without an mcp: declaration. Do not put routes other than tool handlers on these paths. Statically served parts are not affected
10 per app (per Keelson gateway instance; when the gateway scales out, your app can receive more concurrent calls than this in total)
Calls over the rate or concurrency limit are returned to the AI client with a request to retry after a short wait. Limits do not change by plan. The concurrency limit of 10 is not a ceiling on the concurrent calls your app receives, so do not size database connection pools or similar resources on the assumption of 10.
Whether the user has permission on the app (permission: manage tools need manage permission). For a write tool, whether the user approved it on the consent screen. Whether mcp.enabled is true. Whether MCP is stopped for the workspace (shown in the status on the app’s MCP tab)
Calls are rejected in a workspace with IP restrictions
IP restrictions also apply to calls through MCP. Calls from claude.ai or ChatGPT come from each provider’s servers, not from the user’s computer, so they are not allowed as is. Review the access sources in IP restrictions
”Invalid tool response” is shown
Whether the handler returns 2xx with a JSON body, and the body is not empty, plain text, or over 1 MiB
Cannot connect after logging in
Whether the AI client identifies itself as claude.ai or chatgpt.com. Whether the account you logged in with is a member who can view the app
Deploy rejects file under mcp as an unsupported key
The Keelson CLI is old. Run keelson upgrade to update to the latest version, then deploy again
Choose the integration method that fits your task: call an API such as OpenAI, receive notifications from another service, or connect to an external database.
Outbound connections from apps to external services are unrestricted, with one exception: the SMTP ports (TCP 25, 465, 587, and 2525) are always blocked. Set required API keys as secrets and call the service from your app. The external service’s contract and rate limits still apply.
To send email, use Keelson’s Email SDK or the HTTPS API of an email service you contract with, such as Resend, SendGrid, or Mailgun. Contracted services cannot be used through their SMTP relay hosts either, so configure the API client rather than an SMTP host and port.
Calls in the other direction—from an external system to your app—require Keelson login by default. For systems that cannot sign in through a browser, use the setup below.
Webhooks notify your app about events in another service. For example, a payment notification can trigger an update to an order’s status.
Receiving a webhook requires an app token with the webhook scope, even if the sender also signs its requests. Declaring auth.endpoints alone does not allow requests authenticated only by the sender’s signature.
First, issue a token for the target app. Replace my-app with its slug.
Implement a receiving handler such as POST /api/webhooks/incoming. Append one of the following paths to your app URL, for example https://acme--my-app.keelson.run, and register it with the sender.
What the sender can configure
Setup
Custom HTTP headers
Use /api/webhooks/incoming as the receiving path and add X-Webhook-Secret: <issued-token>
Receiving URL only
Use /api/webhooks/<issued-token>/incoming as the receiving path
When the token is in the URL, Keelson removes that segment and forwards the request to the app’s /api/webhooks/incoming. Treat URLs containing tokens as secrets. These requests do not include signed-in user information.
Keelson checks token validity, the target app, the webhook scope, and source IP restrictions. Currently, auth.endpoints cannot restrict webhook paths or methods. Check allowed paths and methods in your receiving handler.
If the sender signs requests, receive its signing secret through secrets and verify the signature in your app. The Keelson app token and the sender’s signing secret are separate credentials.
/api/webhooks/email and /api/webhooks/email-events are reserved by the platform and cannot be used for your own webhooks. Do not add them to auth.endpoints.
Replace my-app with the target app’s slug and 203.0.113.0/24 with the actual source IP range.
Tokens have the form keelson_... and are displayed only once, at creation.
--scope api applies under /api/external/; --scope webhook applies under /api/webhooks/.
--allowed-ip restricts source IPs using CIDR ranges; multiple ranges are allowed. These restrictions apply per token, separately from workspace or app IP controls.
You can configure the same settings on the app’s console page. Under keelson apps tokens, use list to list tokens, rotate to replace one, or delete to revoke one.
The caller adds Authorization: Bearer keelson_... and calls a path declared in auth.endpoints.
- /api/external/import# No methods specified means all methods
Before forwarding the request, Keelson checks that the token is valid for the app and purpose, the source IP is allowed, and the path and method match the declaration.
Paths in auth.endpoints use prefix matching, not exact matching. For example, /api/external/status also matches /api/external/status/detail and /api/external/status-old. Check allowed paths and methods in the app too. An api-scoped token cannot call undeclared APIs.
With Keelson’s Email SDK, your app can send email to its users without contracting an email service. Use it for business messages such as password resets, request confirmations, and approval requests.
Email may only be sent as business communication to that app’s users (Acceptable Use Policy 2.3). Marketing campaigns, sending to purchased address lists, and sending to people who do not use the app are not allowed. Recurring notifications must give recipients a way to stop receiving them.
// Store result.send_id to match it with delivery results later
In Python, import it with from keelson import email and call email.send(...). In Go, create a client with email.New("", "") from github.com/keelsonhq/go-sdk/email and call Send. For installation, see Keelson SDK.
Keelson sets the endpoint and token (such as KEELSON_EMAIL_TOKEN) at deploy time, and the SDK reads them. No keelson.yaml setting is needed. Static sites and SPAs have no server to run the SDK, so they cannot send email
The sender is <app slug>@mail.keelson.run by default. You can set the display name (from_name) and reply-to address (reply_to). On the Team plan, you can also send from an allowed address on your own domain (see below)
A message can have up to 50 recipients across To, CC, and BCC. The body needs at least one of text or html
Sends are limited to 30 per 60 seconds, per app and per workspace
The number of recipients per month depends on the plan (Plans and limits). Check this month’s usage under Email in the console’s workspace settings
On the Team plan, apps can send from an address on a domain your workspace owns, such as [email protected]. Each workspace can register up to 10 domains, and each domain can have up to 20 allowed sender addresses. Owners and Admins set this up under Email > Domains in the console’s workspace settings. You need permission to change the domain’s DNS settings.
Under Add Domain, enter the domain. To limit the domain to one app, choose the app in Scope; otherwise, every app in the workspace can use it.
Add the TXT record shown under Ownership check to your DNS, then select Check status. Only one workspace can verify a given domain.
After ownership is confirmed, three CNAME records for email signing (DKIM) appear. Add them to your DNS. Verification usually completes within minutes. Select Check status to see the current status. If the records are not found within 72 hours, verification fails; remove the domain and add it again.
Under Allowed sender addresses, add each address your apps send from.
In your app, pass the address as from_address (Node and Python) or From (Go).
Keelson checks the From address on every send. A send is refused unless the address exactly matches an allowed address (letters are not case-sensitive) on a verified domain of the sending app’s workspace. Removing an allowed address takes effect from the next send
Email is signed with DKIM for your domain, so the signature is aligned with the From address for DMARC. Keelson does not use your domain for the return path (SPF), so SPF is not aligned
The default <app slug>@mail.keelson.run sender needs no allowed addresses and keeps working
If the workspace leaves the Team plan, sends from your own domain are refused. Registered domains and allowed addresses are kept and work again after you return to Team
Bounces, complaints, and the suppression list work the same as for the default sender
If a send is rejected, the SDK throws an error. The error message includes the error code.
HTTP
Error code
Meaning
400
RECIPIENT_SUPPRESSED
A recipient is on the workspace suppression list (see below)
403
EMAIL_SENDING_SUSPENDED
Email sending is suspended for the workspace. Too many permanent bounces or complaints automatically suspend sending for the whole workspace. Contact us to lift the suspension
403
SENDER_DOMAIN_NOT_AVAILABLE
The From address is on your own domain, but the workspace is not on the Team plan
403
DOMAIN_NOT_VERIFIED
The From domain is not verified for the sending app’s workspace, or the domain is limited to another app
403
SENDER_ADDRESS_NOT_ALLOWED
The From address is not an allowed sender address for its domain
422
RECIPIENT_LIMIT_EXCEEDED
More than 50 recipients in total
429
RATE_LIMIT_EXCEEDED
The 60-second send limit was reached. Retry later
429
MONTHLY_QUOTA_EXCEEDED
This month’s recipient limit was reached. It resets next month
Keelson notifies the app’s POST /api/webhooks/email-events when a message is delivered, returned because the address does not exist or for another reason (bounce), or reported as spam by the recipient (complaint). To use these results, implement a handler at this path. No app token or auth.endpoints setting is needed.
Recipients with a permanent bounce or a complaint are added to the workspace suppression list automatically, whether or not the app handles the notification. After that, no app in the same workspace can send to that address (RECIPIENT_SUPPRESSED). Owners and Admins can review and remove addresses under Email in the console’s workspace settings.
Notifications are signed. Verify the signature with the SDK before using the content. Keelson sets the signing key, KEELSON_EMAIL_WEBHOOK_SECRET, automatically.
import*as email from"@keelsonhq/email";
// Handler for POST /api/webhooks/email-events. rawBody is the request body as received
constevent= email.verifyEventWebhookBytes(
rawBody,
req.headers,
process.env.KEELSON_EMAIL_WEBHOOK_SECRET!,
);
if (event.event_type ==="bounce"&& event.bounce_type ==="hard") {
// Look up the send record by event.send_id and mark the address invalid
}
In Python, use email.verify_event_webhook(body, headers, secret). In Go, use email.VerifyEventWebhook(r, secret). The signature covers the raw body bytes, so pass the body before parsing it as JSON.
Field
Content
event_id
Notification ID. The same notification can arrive more than once, so use it to detect duplicates
event_type
delivered, bounce, or complaint
email_address
The recipient address
send_id
The send_id returned when the message was sent. Use it to match the notification with your send record
bounce_type
For bounces, hard (permanent) or soft (temporary)
detail
Additional information
provider
Name of the sending infrastructure used for delivery
timestamp
When the event occurred
A 2xx response completes the notification. If the handler returns 5xx or the notification cannot reach the app, Keelson retries later, up to 4 attempts in total. Notifications that receive a 4xx response are not retried.
To connect to PostgreSQL, MySQL, or your own libSQL instance, set db.mode: none and pass connection details through secrets. See Use an external database.
Your app can have its own email address. Keelson stores every email that arrives as one row in a table in the app’s database and saves its attachments in Files. Your app reads them with SQL; it does not need code that receives mail. To process each email after it is saved, name a background task in on_receive.
Typical uses are a shared support inbox and reading order or invoice emails. Keelson does not receive mail for your own domain. Forward mail to the app’s address from Gmail, Google Workspace, or Microsoft 365 instead (Forward mail to the app).
Tell your AI agent what should happen to each email, and it can write the keelson.yaml settings and the processing task.
Store the order emails forwarded from our sales@ group and register each one as an order. Processing the same email twice must not create a duplicate order.
on_receive: process-mail# optional: a task declared under tasks
tasks:
- name: process-mail
command: "python process_mail.py"
Field
Meaning
email.inbound.enabled
true turns receiving on. The deploy issues the receiving address and creates the table keelson_inbound_emails
email.inbound.on_receive
Optional. The name of a task under tasks. Keelson starts it after each email is saved
The deploy is rejected in these cases:
Error
Cause
email_inbound_requires_libsql
enabled: true without db.mode: libsql
email_on_receive_requires_enabled
on_receive is set but enabled is not true
email_on_receive_unknown_task
on_receive does not match any tasks[].name
With on_receive, an app that has tasks needs neither command nor crons; receiving is what starts the task. The location of attachments is fixed and cannot be configured.
The receiving address appears on the app’s Email settings page in the console. It has the form in+<random>@inbound.keelson.run. The random part is not derived from the slug, so renaming the app does not change the address.
Rotate: you can issue a new address from the console. The old address is retired immediately, and mail sent to it is refused.
Turn receiving off: deploying without email.inbound (or with enabled: false) retires the address permanently. Emails not yet saved, or held because they could not be saved, are discarded. Rows and attachments already saved stay in place.
Turn it on again: a new address is issued. Update the forwarding settings to the new address.
A retired address is never reused, by your app or any other.
Each email becomes one row in keelson_inbound_emails. The table is in the app’s own database, so the app reads it with the same connection as its other tables.
Column
Contents
email_id
Primary key. Fixed when the email is received
schema_version
Version of the column layout when the row was written. Currently 1
received_at
Time received, ISO 8601 UTC
envelope_to
The app’s receiving address the email was delivered to
envelope_from
The sender address used for delivery (the envelope sender)
from_address
The sender’s address: X-Original-From if present, otherwise From. Not authenticated (below)
from_name
The sender’s display name, from the same header
header_from
The raw From header, after any rewriting by a forwarding group
reply_to
Reply-To header
to_addresses
JSON array of the To addresses (for forwarded mail, your shared inbox)
cc_addresses
JSON array of the Cc addresses
subject
Subject
message_id
Message-ID header
in_reply_to
In-Reply-To header
references_header
References header
list_id
List-Id header
text_body
Plain-text body, decoded to UTF-8
html_body
HTML body, decoded to UTF-8
body_truncated
1 if either body was longer than 1 MiB and was cut
Keelson creates keelson_inbound_emails on the deploy that turns receiving on. Do not create it yourself in migrations or app code. If a table with the same name already exists and Keelson did not create it, the deploy stops.
The table is yours: the app can read, update, and delete rows. Keep your own state, such as “handled” or “assignee,” in a separate table keyed by email_id.
Keelson only inserts rows. It never rewrites existing rows or recreates the table.
Do not remove the email_id primary key, drop columns, or add triggers to the table, and do not save your own files under the Files key inbound/. New emails then cannot be saved and are held (Emails that could not be saved).
Restoring the database from a backup also restores this table to that point.
The task reads the row from keelson_inbound_emails by email_id. Other than how it is started, it is an ordinary background task (Background tasks).
The task is started at least once. The same email_id can be passed more than once, and failed attempts are retried. Record which emails you have handled, and write the record and the result in one transaction:
"CREATE TABLE IF NOT EXISTS handled_emails (email_id TEXT PRIMARY KEY, handled_at TEXT NOT NULL)"
)
cur = conn.execute(
"INSERT INTO handled_emails (email_id, handled_at) VALUES (?, datetime('now')) "
"ON CONFLICT (email_id) DO NOTHING",
(email_id,),
)
if cur.rowcount ==0:
sys.exit(0) # already handled
(subject,) = conn.execute(
"SELECT subject FROM keelson_inbound_emails WHERE email_id = ?",
(email_id,),
).fetchone()
conn.execute("INSERT INTO orders (email_id, title) VALUES (?, ?)", (email_id, subject))
conn.commit() # the handled record and the order are saved together
If the task fails before the commit, nothing is saved and the retry handles the email from the start. For external APIs, pass email_id as the idempotency key.
From the console you can start on_receive again for a saved email. Each start counts the same as an automatic one.
Tasks started by on_receive do not count toward Background Jobs runs. Their run time counts toward the monthly Background Jobs runtime. If the runtime allowance is used up, the task is not started; the console shows this, and it is not retried automatically.
Heavy processing can use up the runtime allowance and stop scheduled jobs too. Check the monthly usage in the console.
from_address comes from the X-Original-From or From header. Anyone can write any address there. Do not use from_address to identify a user or to decide what an email is allowed to do, for example “emails from the CEO approve a payment.” Use it for display and for sorting mail only.
auth_spf, auth_dkim, and auth_dmarc record checks made by the receiving server. Forwarding often breaks them, so they do not prove who sent the email either.
In Gmail, open Settings → See all settings → Forwarding and POP/IMAP and add the app’s receiving address as a forwarding address.
Gmail sends a confirmation email from [email protected] to that address. Open Recent emails on the app’s Email settings page in the console and read the confirmation code. This works even when the app is not running.
Enter the code in Gmail and turn on forwarding. To forward only some emails, create a Gmail filter that forwards matching messages.
The confirmation email is also stored in the table like any other email. If you use on_receive, the task is started for it too.
Add the app’s receiving address as a member of the Google Group that receives the mail (for example, support@). The group then delivers a copy of each email to the app.
If the address cannot be added, your Google Workspace administrator needs to allow groups to have members outside the organization.
When the original sender’s domain has a strict DMARC policy, Google Groups rewrites From to the group’s address and keeps the original sender in X-Original-From. from_address uses X-Original-From, so it holds the original sender; header_from holds the rewritten From. to_addresses holds the group address.
Microsoft 365 blocks automatic forwarding to external addresses by default (the outbound spam filter policy). An administrator must allow it before forwarding works. Otherwise forwarded mail bounces with 550 5.7.520 Access denied, Your organization does not allow external forwarding.
In the Microsoft Defender portal, open the outbound anti-spam policy and set automatic forwarding to on. Prefer a custom policy that applies only to the mailbox that forwards.
In that mailbox, set up forwarding to the app’s address (Outlook forwarding settings or an inbox rule), or use a mail flow rule.
Keelson has not tested forwarding from Microsoft 365. These steps are based on Microsoft’s documentation.
25 MiB, including attachments. Larger emails are refused
Body
text_body and html_body are each cut at 1 MiB (body_truncated = 1)
Attachments
No limit per attachment within the 25 MiB
Emails received per month
Per workspace, all apps combined. See below
Monthly receiving limit (per workspace, calendar month in UTC):
Starter
Plus
Team
Business
Emails received per month
500
3,000
10,000
30,000
An email is counted once, after duplicates are removed. Workspace Owners and Admins are notified at 80%, 100%, and 150%.
Above 100%: emails are still received and stored, but on_receive is not started for them. The console marks those emails. After upgrading your plan, you can start on_receive for them from the console.
At 150%: the workspace’s addresses refuse mail, and senders get a bounce. Receiving resumes at 00:00 UTC on the 1st of the next month or after a plan change. A few emails that arrive while this takes effect may still be stored.
If an email cannot be written to the table, for example because the table was dropped, its primary key was removed, a trigger rejected the row, or a different file already exists under inbound/, Keelson holds the email. The console’s email history shows the reason and how to fix it. After fixing the table, retry the email from the console.
Held emails are kept for 7 days, up to 1,000 per app. While the app is at 1,000 held emails, new mail to its address is refused.
After 7 days a held email is recorded as expired, and the email and its attachments are deleted.
The email history in the console also shows emails waiting to be saved, emails that could not be read as email at all, and emails discarded because receiving was turned off.
spam_score is a simple score. Keelson does not quarantine or delete mail based on it, and every email is stored. Forwarded mail often fails SPF, so ordinary mail can get a score too. Decide in your app what to do with high scores.
Check the console. Open the email history on the app’s Email settings page. If the email is there, Keelson received it; check its state (held, and why).
Check the address. Mail to a rotated address, or to an app with receiving turned off, is refused.
Check the limits. At 150% of the monthly limit, or with 1,000 held emails, new mail is refused. Emails over 25 MiB are refused.
Check the forwarding. Gmail forwarding starts only after you enter the confirmation code. Microsoft 365 blocks external forwarding unless an administrator allows it.
Authentication checks before Keelson. Mail that fails both SPF and DKIM, or that fails the sender domain’s DMARC policy, is rejected by the receiving service before it reaches Keelson. Such mail leaves no record in Keelson, and the sender usually gets a bounce. Forwarding that changes the message can cause this. Forwarding through a Google Group delivers mail from such senders.
keelson dev email stores a saved email file (.eml) in your local database and Files, the same way Keelson stores received mail.
Terminal window
export KEELSON_DB_URL=file:local.db
keelsondevemail./samples/order.eml
keelson dev email <file.eml> [--to <address>]
The message is parsed with the same mapping as on Keelson and inserted as one row into keelson_inbound_emails in the database at KEELSON_DB_URL. If the table does not exist, it is created (locally only).
Attachments are written under KEELSON_FILES_DIR (default ./.keelson/files) at inbound/<email_id>/<attachment_id>, where the Files SDK reads them in local development.
KEELSON_DB_URL must point to your machine: a file: URL, or an http://, libsql://, or ws:// URL whose host is 127.0.0.1 or localhost. Otherwise, or if it is unset, the command exits with code 2 without writing anything.
--to sets the receiving address (envelope_to). The default is [email protected].
The output shows the email_id, the subject, the number of attachments, and where they were written.
email_id is derived from the message contents and --to. Running the same file again prints Already stored and exits with code 0.
A file that cannot be parsed as an email exits with code 1.
on_receive is not started. If keelson.yaml declares it, the command prints how to run the task:
This page covers production startup commands and considerations for each framework. Start your web server on the port specified by the PORT environment variable. Some frameworks have database or connection constraints, so also check Supported apps and constraints.
Use a production server. Development servers can have performance issues, expose internal information in error pages, and handle in-flight requests poorly during shutdown.
Your AI agent can usually handle configuration. Refer to the section for your framework when checking startup settings or investigating deployment problems.
Listen on 0.0.0.0 and the port specified by PORT. Requests cannot reach a server bound to 127.0.0.1.
Do not install dependencies in the startup command. Dependencies are installed at build time from requirements.txt, pyproject.toml, package.json, go.mod, or other supported files.
Shutdown sends SIGTERM, then SIGKILL after 10 seconds. Finish in-flight requests within 10 seconds.
Start with one worker. If you add workers, account for available CPU, memory, and the work your app performs.
Disable debug mode by default. Debug pages may expose environment variables, including database authentication tokens.
Use KEELSON_MODE to detect Keelson. A value of keelson means the app is on the platform. Keep environment detection separate from enabling or disabling debug mode.
Django deploys only when it connects to an external database such as PostgreSQL. Managed SQLite (libSQL) has no backend for the Django ORM, so configurations that use Managed SQLite or a SQLite file as the database are not supported.
Set db.mode: none and pass the connection details as secrets. Start with gunicorn, not runserver.
Replace config.wsgi:application with your project’s WSGI module. Keep DEBUG off by default, serve static files with whitenoise (set STATIC_ROOT and run collectstatic in the build), and keep ALLOWED_HOSTS = ["*"]. See the Deploy Spec for details.
Apps using file-based SQLite may be able to connect to Managed SQLite with sqlalchemy-libsql-native. This approach is experimental. Beyond changing the connection settings, verify that models, queries, and migrations work. Ask your AI agent to review the required changes and compatibility.
NODE_ENV is not set automatically. Declare it in keelson.yaml. Without it, frameworks such as Express may include stack traces in responses.
env:
NODE_ENV: "production"
Keelson uses npm for builds on the platform. If your project only has pnpm-lock.yaml or yarn.lock, preflight checks stop the deployment. Generate package-lock.json and verify that installation and building succeed with npm. Static sites built locally with pnpm or Yarn can still be served. See Dependency installation.
If you use trust proxy to determine the client IP or whether a request uses HTTPS, configure it for the actual proxy path and forwarded headers. A fixed value of 1 may not work correctly (official Express guide).
Declare secrets for your app’s own sessions or JWTs in secrets.
Keep Prisma’s provider set to "sqlite" when connecting to Managed SQLite. Change the connection method: replace the local file connection with a Prisma Client using @prisma/adapter-libsql, KEELSON_DB_URL, and KEELSON_DB_AUTH_TOKEN (connection instructions).
Use compatible versions of @prisma/adapter-libsql and @prisma/client (6.x has been tested). Configuring the adapter does not automatically apply Prisma migrations to the remote database. Ask your AI agent to verify queries with your version and how to apply generated SQL to Managed SQLite.
Keelson builds with go build -o /workspace/app . and starts the app with ./app.
runtime: go-slim
command: "./app"
Builds use CGO_ENABLED=0. cgo SQLite drivers such as mattn/go-sqlite3 cannot be used. Use a pure Go libSQL client for Managed SQLite.
Handle shutdown explicitly with signal.NotifyContext and server.Shutdown, so in-flight requests finish within 10 seconds.
If you use an ORM such as GORM, check database and driver compatibility. If it cannot connect to Managed SQLite, switch to database/sql and a pure Go libSQL client.
For Vite, Next.js static exports, Astro, and similar tools, build locally and deploy the output through assets.dir. Keelson does not install dependencies or build static sites that have no command.
Static sites require type: web. Files in assets.dir are included in the upload even if the folder, such as dist/, is in .gitignore. See Configure keelson.yaml for examples.
For hybrid apps combining a static frontend and API server, also build the frontend locally before deploying.
Open the app folder in your AI agent and send a request like this. The Quickstart explains how to install the Keelson Agent Skill.
Deploy this app to Keelson. Check the required configuration, then give me the app URL and the results of your checks when deployment is complete.
Ask the agent to:
Check that the app’s storage and startup settings work with Keelson.
Create or update keelson.yaml and explain where to register API keys and other required values.
Deploy and monitor the deployment through completion.
If it fails, inspect the logs and explain the cause and how to resolve it.
Verify the deployed app and report what was checked.
The first deployment creates the app. Later deployments update the app identified by the slug in keelson.yaml within the same workspace. A slug is the name used to identify an app.
For a web app or static site, open the reported URL and check the page and main actions. See Verify a deployment for a checklist and an example request for your agent.
For an app that only runs scheduled jobs, check its schedule and results in the console’s Scheduled Jobs tab instead of opening a page. See Scheduled jobs for running a job manually.
Give your agent any error code (code) and suggested action (hint) from the error message to help it investigate. See Troubleshooting for logs and help by symptom.
If an update fails before traffic switches to the new version, the previous version stays accessible. Database changes are not automatically undone, however. For updates that change the database, follow Update and restore to check its state.
The following details help when inspecting configuration or logs. For web apps that serve pages or APIs from a server, deployment proceeds through these stages:
Validation — checks keelson.yaml and the files being uploaded.
Build — installs dependencies and packages the files needed to run the app into a container image. The build limit is 600 seconds.
Startup — starts the app with the new code and configuration. This running version is called a revision.
Health checks — checks that the app responds at health.path (default /) and the paths in verify.
Migration — runs db.migrate, if configured, to update the database structure or data.
Traffic switch — directs users to the new revision.
Static sites and SPAs serve prebuilt HTML, JavaScript, and other files without starting a web server. Apps that only run scheduled jobs do not have web traffic to switch; their jobs run at the configured times.
Storage and startup adjustments may include moving from a local SQLite file to Managed SQLite or reading the PORT supplied by Keelson. See Supported apps and constraints.
After deployment finishes, open the app and check the actions you use regularly. You can ask your AI agent to check that pages and APIs respond correctly.
Do the main actions, such as listing items and searching, work?
If the app saves data, can you save and read back test data?
Before sharing the app, also check that the intended users have access. Being able to open it yourself does not mean others can. See App URLs and domains.
For an app that only runs scheduled jobs, check the execution results in the console’s Scheduled Jobs tab.
Check the main pages and APIs of the deployed app. Tell me what you verified and what remains unchecked. Use test data for checks that change data.
A successful response does not prove that calculations or business rules are correct. Give the agent typical inputs and expected results so it can check those too.
Regular app URLs require authentication, so a plain curl request without credentials cannot verify them. keelson app curl sends requests with the permissions of the account signed in to the CLI.
A preview token is a temporary credential for accessing the app as that user. app curl obtains one internally, so you do not need to handle the token value.
The response body goes to standard output. The method, URL, and status go to standard error. With -i, headers also go to standard error, so you can save just the body to a file.
The default method is GET. Explicitly using --method POST, PUT, PATCH, or DELETE obtains a token that allows writes and lasts five minutes.
--data can only be used with write methods. Use @filename to read data from a file.
--form sets the method to POST. The CLI adds the multipart Content-Type; you cannot override it with --header.
Paths must start with /. Absolute URLs and paths starting with // are rejected. Redirects are not followed.
You can also check /__keelson/media/... and /__keelson/assets/... with GET or HEAD.
Add --app <slug> to specify the target app.
Ask the agent to check Content-Type as well as the status. For example, a SPA might return HTML instead of a missing JavaScript file. A 200 response in that case does not mean the page will work.
Use another tool or make multiple requests with keelson preview
The token is shown once on standard output and is not stored by Keelson. Do not include it in logs or reports. Only one keelson preview token can exist per app and user. Issuing another revokes the previous token, even if it has not expired. keelson app curl uses a separate internal token and does not revoke the token issued by preview.
If you did not create the currently served deployment, the CLI provides an approval URL to open in a browser before issuing a preview token. Approval requires the app’s manage permission (manage). You can approve your own request if you have that permission.
Review the app, write access, and lifetime on the approval page, then approve token issuance. Rerun the same command with the supplied --confirmation <id>.
These commands retrieve responses using your permissions. Check browser appearance and interactions, and other users’ access, separately.
Static sites have no write destination, so write methods are rejected.
Even a GET request can change data or send email if the app implements it that way. Ask the agent to inspect the code first and avoid paths that would cause unintended changes.
You can configure API keys, service URLs, and other values for your app. Environment variables are how these values reach your code. Store confidential values, such as API keys, as Keelson secrets.
Type
Examples
Where to set it
Non-secret configuration
NODE_ENV, LOG_LEVEL, public API URLs
env in keelson.yaml
Secret values
API keys, tokens, database connection strings, webhook signing secrets
Secrets, using the console, CLI, or an env file at deployment
Keep keelson.yaml with your source code. Do not include secret values in it; register them as secrets instead.
List the configuration values and API keys this app needs. Document their names and purpose, and create a local file where I can enter the values. Once I have filled it in, register and apply them in Keelson.
Your agent can prepare the configuration, implement code that reads the values, and register and apply secrets. Enter secret values in a local configuration file instead of pasting them into chat, then ask the agent to register them from that file. You can also enter them yourself in the console.
An AI agent with the Keelson Agent Skill can register secrets from a local env file, either on the first deployment or when updating an existing app.
Register the values in .env.keelson as this app’s secrets and apply them. Do not print the values in chat or logs.
The following illustrates the file format. Ask your agent to prepare the keys your app needs, then fill in their values.
.env.keelson
OPENAI_API_KEY=sk-...
SLACK_WEBHOOK_URL=https://hooks.slack.com/...
To enter values yourself, use the Secrets tab of an existing app to add, update, or delete them. After saving, only the names and update times are shown; values cannot be displayed again.
You or your agent can also register and apply them with the CLI:
The file passed to --secrets-from-env-file is excluded from the upload. Do not add files containing secrets to Git or put secret values directly in commands, where they may remain in shell history.
Saving a secret does not update an app that is already running. Ask your agent to both register and apply the values. They take effect during the first deployment; for existing apps, use one of these options:
If you are also changing code, run keelson deploy.
To apply values only, click Apply changes in the console’s pending-changes notice.
To register and apply in one CLI command, use secrets set --apply as shown above.
Apply changes and --apply redeploy without uploading or rebuilding the code. They use the last successful deployment’s container image to start the app with the new values. Once complete, test an action that uses the API key or other changed value.
The secrets section in keelson.yaml can list secret names, their purpose, and the conditions that make them required. Do not include values. This declaration is optional. Registered secrets are passed to the app as environment variables even without a declaration.
Declarations document the required configuration and let deployment checks catch missing values. Ask your agent to identify the keys and requirements.
secrets:
items:
- name: OPENAI_API_KEY
description: "OpenAI API key"
- name: SLACK_WEBHOOK_URL
description: "Slack webhook for notifications"
required:
- all_of: [OPENAI_API_KEY]
message: "Set OPENAI_API_KEY"
items lists names and descriptions, which also appear in the console. Listing a key here does not make it required.
required defines the conditions for deployment. If the registered secrets do not satisfy them, deployment fails with the specified reason. In this example, a missing OPENAI_API_KEY displays “Set OPENAI_API_KEY”. Register the value, then deploy again.
SLACK_WEBHOOK_URL is listed but not required, so its absence does not block deployment at this check.
Without required, Keelson does not perform this required-secret check. Missing values may instead cause errors when the app starts or performs an action that uses the key.
Neither env nor secrets are supplied during builds, including dependency installation and npm run build. The build can only read the uploaded source files.
Put public values needed by a frontend build, such as VITE_API_URL, in configuration files within the source. These values are distributed to browsers, so do not put credentials there.
Keelson sets these platform-managed variables for apps. Do not define or override them yourself.
Variable
Purpose
PORT
The listening port for a web app.
TZ
The workspace time zone.
KEELSON_MODE
Indicates platform mode.
KEELSON_APP_ID
The internal app ID.
KEELSON_WORKSPACE_ID
The internal workspace ID.
KEELSON_TENANT_ID
Compatibility alias for KEELSON_WORKSPACE_ID, with the same value.
KEELSON_DEPLOY_ID
The internal ID of the current deployment.
KEELSON_APP_URL
The standard app URL.
KEELSON_DIRECTORY_BASE_URL
The Directory API URL.
Additional variables are set when features are enabled. See the Environment variable reference for the complete list, the conditions for each variable, and compatibility variables.
NODE_ENV is not set automatically. Add it to env if your app needs it.
Knowing the URL is not enough. A visitor must be signed in to the workspace and have permission to view the app. If IP restrictions are configured, they must also connect from an allowed network.
Share the app URL directly with App Users. They cannot open the console, so they cannot look it up there. Copy it using one of the methods above and send it to them by chat or email.
Owners and Admins can change an app’s URL after creation. Use Public URL in the app’s console settings.
The old URL stops working and does not redirect to the new one. Share the new URL with users and update bookmarks and external service settings. Before saving, select the checkbox confirming that you understand the impact.
The old URL is reserved for 30 days so another app cannot take it. Requests to it receive 410 Gone during that period. It is released automatically after 30 days.
Use the console or keelson apps rename <new_slug> to change the URL. The slug in keelson.yaml identifies the deployment target; changing it alone does not change the URL. Without an explicit --app, the next deployment looks for an app matching the new slug and can no longer update the original app. If you also change the app’s identifier in the console, update keelson.yaml to match before deploying again.
You can also change the workspace slug in settings, but existing app URLs stay the same. To use the new workspace slug in those URLs, update each app’s public URL individually.
The Team plan lets you use a company subdomain such as app.example.com. You need permission to change the domain’s DNS settings. If a colleague manages your domain, send them the values Keelson provides.
Available on Team: up to 10 domains per workspace and one domain per app.
Subdomains only. Root domains such as example.com and email domains are not supported. To send email from your domain, see Send from your own domain.
Authentication, permissions, and IP restrictions also apply through custom domains.
Follow these steps:
An Owner or Admin registers the domain in the app settings or with keelson domains add <hostname> [--app <slug>]. Keelson shows the CNAME target to configure.
At your DNS provider, point the subdomain’s CNAME to that target. In production, the target is custom-origin.keelson.run. Use the value shown in the console or CLI.
Check that the domain is active in the console or with keelson domains verify <hostname> [--app <slug>]. Activation may take a few minutes.
Keelson automatically verifies ownership, issues the HTTPS certificate, and configures serving.
Use this when your app needs to create links to itself, such as links in emails. You can ask your agent to configure it.
Keelson supplies the standard app URL in KEELSON_APP_URL. The request’s Host header contains an internal hostname, so use KEELSON_APP_URL for absolute URLs in emails, redirects, or og:url.
import os
base_url = os.environ["KEELSON_APP_URL"] # e.g. https://acme--dashboard.keelson.run
This variable is not set during local development. Use KEELSON_MODE to detect whether the app is running on Keelson.
Changing the public URL does not immediately update the environment of a running app. Redeploy apps that use KEELSON_APP_URL, then check that email links and redirects use the new URL.
Setting a custom domain does not change KEELSON_APP_URL: it still contains the standard URL. To use the custom domain in email links and similar places, ask your agent to store it as an app configuration value. See Environment variables and secrets.
Use the console’s status indicators and logs to check whether an app is available and investigate problems. Logs record app activity, errors, and access requests.
Status badges appear in the app list and on each app’s page.
Status
Meaning
Active
Recent requests are using one concurrent app slot
Sleeping
Deployed, but not using a slot because there have been no recent requests. A stopped app starts automatically on access
Published
Serving a static site or SPA. Keelson returns the page files directly
Suspended
Manually stopped. Requests do not start the app or resume serving. Data, URL, and configuration are retained
Starting / Deploying
Starting or stopping, or applying new code or configuration
Not deployed
Created, but not yet deployed
Error
The latest deployment or execution failed. The app page shows the cause
Deleting
Deletion is in progress. The app disappears from the list when it finishes
—
An app that only runs scheduled jobs. Check individual results in the Scheduled Jobs tab
Active and Sleeping describe usage, not whether a server process is currently alive. Web apps stop after a period without requests and start on the next request. See When apps run.
Apps accessed within the last five minutes normally occupy a slot. Priority startup reserves a slot even without requests. When no slot is available, users see a page asking them to wait. An unreserved slot is released about five minutes after the last request to its app.
An Error badge may refer to the latest failed deployment even while the previous version remains available. Check the failure in Overview and try opening the app.
To temporarily stop an app, open Settings → Suspend on its page. This applies to web apps and static sites or SPAs. While suspended, requests, webhooks, and scheduled jobs do not start it, and static files are not served. The standard Keelson URL shows a stopped page, and custom-domain serving also stops. Data, URLs, and configuration are retained.
Use Resume in the app page header to resume it. Deploying a static site or SPA while it is suspended does not resume serving; you must explicitly resume it. Static serving changes propagate gradually, so stopping or resuming may take a little time.
Apps that only run scheduled jobs have no console Suspend action. To stop their schedules, set each job’s enabled to false and deploy. Set it back to true and deploy to resume. See Scheduled jobs.
Turn on Priority start in the app page header to reserve one concurrent app slot. This prevents the app from being blocked because all slots are occupied.
The app still stops after a period without requests. Users may need to wait for startup on the next request.
On paid subscriptions, available on Plus and higher plans; you can reserve up to one fewer than your total slot count. During the trial, all plans can enable it for up to 2 apps.
App logs can be filtered between web and scheduled-job (cron) execution. The console shows the most recent 200 lines; exports include up to 5,000 lines. Access logs can be filtered by users, machines (app tokens), and webhooks. Retention depends on your plan.
Use keelson logs from the CLI. For app logs in a format an agent can process, use keelson logs app <slug> --json. See Identify the failing stage for logs to inspect after a failed deployment.
The Deploys tab lists past deployments. You can download each deployment’s source. Deployments that failed or have warnings also show Details.
A rollback returns the code to an earlier deployment. It does not restore database contents, and static sites or SPAs require redeploying earlier files. See Restore an earlier version of the code for instructions and limits.
Redeploy in the app page header restarts the app using the last successful deployment’s runnable files. Applying secrets also redeploys without uploading or rebuilding the code. See Apply saved values to the app.
To retire an app, use Settings → Danger Zone to delete it. Browser approval is required, and you cannot undo the deletion yourself. See Retire an app for what to export first and how long data is retained. Use Suspend above if you only want a temporary stop.
Tell your agent what changed and which actions to check.
Deploy these changes to Keelson. Check the configuration and upload contents before deploying, then verify that [action] works afterward.
Check your changes in your local development environment.
Ask the agent to deploy. With the CLI, use keelson deploy --check --json to inspect configuration and upload contents, then keelson deploy to apply them.
After deployment, open the app and test the changed behavior. For apps that only run scheduled jobs, check the run history. See Verify a deployment.
If deployment fails before the traffic switch, the previous version remains accessible. However, changes made by a partially completed db.migrate remain. See “Change the database schema” below.
See When changes take effect for details such as the deployment or Apply step required after saving secrets.
A rollback returns the code to an earlier deployment. The target must be a successful deployment whose container image, the packaged files used to run the app, is still available.
You can ask your agent: “After [problem], check whether we can return to the previous code, including any impact on data.” The CLI commands are:
Terminal window
keelsonrollback# Return to the previous successful deployment
keelsonrollback<deploy_id># Return to a specified deployment
The app starts using the target deployment’s image and keelson.yaml settings, without rebuilding.
Only completed deployments with an image can be targets. Static site / SPA deployments and deployments created with an older deployment method cannot be rollback targets.
Secrets use their current values.
If the target deployment’s keelson.yaml includes db.migrate, it also runs during rollback before traffic switches. A rollback can therefore modify the database.
The database is not restored. If the update changed its schema, check that the older code can use that schema. Even adding a column can affect compatibility through constraints or defaults. Ask your agent to check compatibility and consider a database restore if needed.
Rollback cannot run while a deployment is in progress.
Past deployments appear in the console’s Deploys tab, where you can download their source.
For a static site or SPA, redeploy the earlier files. Either return to an earlier Git commit and build locally, or download the source of an earlier deployment from the Deployments tab: the contents of assets.dir and keelson.yaml. To use the downloaded archive, extract it and run keelson deploy in the folder containing keelson.yaml. No rebuild is needed.
Restore Managed SQLite data (db.mode: libsql) from Restore points on the app page in the console.
Select a point on the restore timeline: a daily backup, a manually saved snapshot, or a specific time using point-in-time recovery (PITR). See Plans and limits for retention counts and recovery windows.
You can Download the selected restore point to inspect it. This downloads that historical state, not the current database.
Select Restore and approve the operation in your browser.
Open the app and check the data after restoration.
What happens during a restore:
Database writes are temporarily unavailable during the switch. Notify users and choose a quiet period.
All writes after the selected point are lost. This does not recover only the deleted records. To keep the current state too, create a manual snapshot of the current database and download it before restoring. Ask your agent how to bring back any later changes you still need.
The state before restoration is retained for 72 hours, during which you can undo the restore.
A restore affects only one app’s database, not other apps.
Files / Media files and external databases are not included.
Before a major change, take a manual snapshot to create a known recovery point. You can create five per app per day.
A migration updates the database structure, for example by adding fields. Ask your agent to check that returning to the earlier code will still work:
Before changing the database schema, check compatibility with the previous code and whether the migration can safely run again after a failure. For changes that may require a restore, explain the impact and recovery steps before running them.
The command in db.migrate runs before traffic switches during a deployment or rollback.
If it fails, the previous version remains accessible, but partially applied database changes are not automatically undone. Inspect keelson logs deploy <deploy_id> and the database state before making corrections.
Make repeated runs safe so the same change is not applied twice. This property is called idempotency. Track applied changes or use constructs such as IF NOT EXISTS, and check what happens after a partial failure.
Check compatibility with the previous code even when adding columns or tables. For higher-impact changes, such as removing columns or changing types, consider taking a snapshot immediately beforehand and running during a quiet period.
Suspend, under Settings → Suspend for web apps and static sites / SPAs
Requests, webhooks, and cron do not start the app. The URL shows a stopped page. Data, URL, and configuration remain
Yes, use Resume
Delete, under Settings → Danger Zone
Removes the app, URL, deployment history, and secrets. Requires browser approval
No
Apps that only run scheduled jobs have no console Suspend action. Set each job’s enabled to false and deploy to stop scheduling; set it back to true and deploy to resume. See Scheduled jobs.
Export these before deletion:
Database data — for the current state, create a manual snapshot and download that restore point.
Files files — add download functionality to the app and export them.
Media files — create viewing links from the file IDs recorded by the app and save the files before deleting. Neither Files nor Media has a console or CLI listing or download feature. See File management and capacity.
Source code — keep it in Git or download it from a deployment in the Deployments tab.
Secret values — saved values cannot be displayed again. Retrieve them from where you originally stored them or from the issuing service.
After deletion, database backups, Files files, and non-sensitive configuration notes (app name, URL, cron definitions, and environment variable and secret names) are retained in isolation for seven days. This allows discretionary assistance with accidental deletion; it does not guarantee recovery.
Use this page when deployment fails or your app does not behave as expected. Tell your AI agent what happened and share the error message to ask it to investigate.
An AI agent with the Keelson Agent Skill can use the CLI to retrieve app status, deployment logs, app logs, and access logs. It uses the permissions of the account signed in to the CLI, so that account needs permission to view the app’s logs. You can give the agent the app name, time, and symptom without finding and pasting the logs yourself.
In this app, [action] causes [symptom]. It happened at [date and time]. Check the status and logs, then explain the cause and how to resolve it.
Include any error code (code), message (message), and suggested action (hint). If the issue cannot be resolved by changing the app, such as a service outage, gather the information under Contact support.
On the app page in the console, Overview summarizes the failure, Details on a deployment under Deploys shows its failure details, and Logs shows app logs. Start with the overview, then ask your agent to investigate the details.
A deploy_id identifies a deployment. Find it in CLI output or the console’s Deploys tab.
Terminal window
keelsonstatus# App and latest deployment status
keelsondiagnose<deploy_id># Diagnose a failed deployment
keelsonlogsdeploy<deploy_id># Progress and stored failure details
keelsonlogsapp<slug># App standard output and standard error
Even if the app exited during startup, keelson logs deploy and keelson diagnose can show saved startup logs. These may identify the failing code or missing configuration.
Which logs are saved
Failed deployments retain build and startup details. Use them to investigate dependency, Dockerfile, secret, configuration, and migration errors afterward. Check saved startup logs for tracebacks and process errors.
Build output for successful deployments is not retained. If neither progress logs nor saved failure details are available, the CLI explains why logs are unavailable.
Stage
Where to look
Common causes
Before upload: CLI preflight checks
Command output
Missing go.mod for Go; only pnpm / yarn lockfiles for Node; pip install in command; missing db.mode; unquoted env values
Validation
code and message
Leftover workers / databases, reserved names, plan limits
Build
Deployment logs
Dependency resolution failure, mismatched package.json and package-lock.json, private registries, Go packages requiring cgo, build script errors
Startup
Saved app startup logs from keelson logs deploy <deploy_id>
Not listening on 0.0.0.0, hardcoded PORT, exceptions caused by missing secrets
Health checks
Saved startup logs, then app logs
App exits during startup, / returns 5xx, startup exceeds the health-check deadline
Migration
Deployment logs
SQL errors in db.migrate. The previous version remains accessible, but database changes made by the migration are not automatically undone. Check the database before correcting the issue
Ctrl-C stops only this client’s monitoring. Deployment continues on the server and may still switch traffic to the new version. Check progress using the deploy_id shown by the CLI.
Terminal window
keelsonstatus<deploy_id>
If you do not want the new version, restore the previous code after deployment completes. This command returns to a previous successful deployment whose container image is still available. Database changes are not restored.
Configuration or storage may differ between your computer and Keelson. Ask your agent to check:
Listening only for local connections — change the listening address from localhost to 0.0.0.0.
A hardcoded port — read the PORT environment variable.
Files available only locally — examples include .env, local database files, or files excluded by .gitignore. The archive.excluded field from keelson deploy --check --json shows what was left out.
Missing environment variables — values present only in your local .env are not available on Keelson. Declare the names in secrets and register the values.
Local SQLite files — files in /tmp or the app directory do not survive restarts (/data does not exist and cannot be created). Consider migrating to Managed SQLite.
Keelson’s Node builder uses npm, so pin dependencies with package-lock.json. Ask your agent to run npm install in the project root, check dependency consistency, and deploy again. Keep the generated lockfile with your source code.
You do not need to remove pnpm-lock.yaml or yarn.lock. They can coexist with package-lock.json; the build uses package-lock.json.
Check whether the app is storing data in files on its server. Files in /tmp and the app directory are lost on restart, redeployment, or idle shutdown. Use a database or the Files / Media SDK.
Server-side work that continues after sending a response is not guaranteed to finish. In-process timers also stop when the app is stopped.
Ask your agent to finish work required by the current request before returning the response, enqueue work that continues after the response as a background task, and move recurring work to Keelson scheduled jobs. Another option is to store pending work in the database and process it with a scheduled job, but it must wait until the next run. Browser-side timers are not subject to this restriction. See When apps run.
Your browser may have cached old files. Confirm that deployment completed, then force a refresh: Cmd + Shift + R on Mac or Ctrl + Shift + R on Windows.
Static site updates propagate gradually and may take a little time after deployment completes. If nothing changes, ask your agent to check the target app and the files that were uploaded.
The concurrent app limit has been reached. Apps accessed within the last five minutes and apps with Priority startup occupy slots. Wait about five minutes after requests stop to an app using an unreserved slot, enable Priority startup for an important app, or upgrade your plan.
This can occur when a response takes too long. Keelson allows 120 seconds for the response to begin, 300 seconds in total including streaming, and 120 seconds between streamed responses. See HTTP time limits.
Ask your agent to find the slow operation and consider shortening it, streaming results incrementally, or moving work to scheduled jobs. Streaming does not remove the time limits.
Even after a 504, the server may have completed a save or send operation. Check the result before trying again. Your implementation needs idempotency: repeated requests must not create duplicate records or messages.
Does the path start with /api/webhooks/ or /api/external/?
For /api/external/: is the path declared in auth.endpoints, and does the method match the declaration? (/api/webhooks/ needs no declaration; methods is not applied there)
Saving secrets does not update an app that is already running. Ask your agent to apply the values too. If doing it yourself, deploy when you also have code changes, or click Apply changes in the console for value-only changes. Apply redeploys without uploading or rebuilding the code. See Apply saved values to the app.
Keelson lets you restrict internal apps to workspace members who have permission to view them. Users open the app URL and sign in on Keelson’s login page. Knowing the URL alone does not give someone access.
Keelson provides the login page and manages login sessions, so you do not need to build these into your app. To choose who can use an app, see Share an App with Your Team.
If you are not signed in, Keelson displays its login page.
Sign in with Google, Microsoft, or the code sent to your email.
You return to the original app. It opens if you have view permission.
While your login session remains active, you can open other apps in the same workspace without signing in again. You still need permission to view each app.
Your app can show the user’s name or display only their own data. You can ask your AI agent: “Use the identity information provided by Keelson to show users only their own data.”
Keelson passes identity information to your server-side code. Your app must implement the rules that determine which data a user can view or update. See Use the Signed-in User’s Identity.
How identity information is passed (HTTP headers)
Keelson adds identity information to authenticated requests as HTTP headers. The main headers are:
Header
Contents
X-Keelson-User-Id
User ID
X-Keelson-User-Email
Email address
X-Keelson-User-Name
Name
X-Keelson-User-App-Perms
Permissions for this app
Headers with these names supplied by the user are removed and replaced with identity information verified by Keelson. Your app uses this information to identify the user. The browser’s Authorization and Cookie headers are also removed.
When someone opens an app in a browser, Keelson checks their login session and permissions. The component that performs these checks before requests reach the app is called a proxy.
Browser
│
▼
Keelson 1. Is the user signed in?
2. Are they an active workspace member?
3. Do they have permission to view this app?
4. Does their network meet the IP restrictions?
│
▼
App Receives requests that pass every check
If check 1 fails, the user is redirected to the login page.
If check 2 or 3 fails, an access denied page appears.
If check 4 fails, an access denied page (HTTP 403) shows the IP address the user is connecting from.
IP restrictions limit access by the network a user connects from. They do not apply to static sites, SPAs, or the static files of hybrid apps. Login, membership, and view permission checks still apply. See IP Restrictions.
Notifications from external services (webhooks) and API calls use an app token instead of a browser login. If the sender signs requests, your app must also verify the signature. A signature alone does not grant access.
Configure the paths and methods allowed for API calls in auth.endpoints in keelson.yaml. This setting does not apply to webhooks, so check allowed paths and methods in your webhook handler.
Tell your AI agent which service you want to connect and which notifications or data you want to receive, and ask it to configure and implement the integration. See External Integrations.
Add the people who will use your apps as workspace members. Choose a role based on whether each person uses apps, builds and updates them, or manages members and workspace settings.
Users must be members to access apps. The apps they can use depend on the app permissions assigned to their groups.
Choose App User for people who only use apps, and Developer for people who build and update them. Choose Admin for people who manage members and workspace settings, or Owner for people who also handle billing and manage other Owners.
Role
Main responsibilities and capabilities
Owner
Responsible for the workspace. Manages members, including other Owners, settings, and billing
Admin
Manages members other than Owners, and workspace settings
Developer
Builds, deploys, and updates apps. Cannot manage members or change workspace settings
App User
Uses apps they have permission to view. Does not perform management operations in the console
Owners, Admins, and Developers count toward the plan’s Developers seats, the allowance for people who build and manage apps. App Users do not count toward this allowance, and their number is unlimited. See Plans and Limits.
Owners and Admins can change roles on the Members page. Only an Owner can assign or change the Owner role. Every workspace must have at least one Owner.
An Owner or Admin invites an email address. The recipient opens the link and accepts the invitation
Join URL
Someone with an allowed email domain opens the URL and signs in to join. No administrator approval is needed
Find a workspace and request to join (Discover)
Someone with an allowed email domain submits a request, which an Owner or Admin approves
If someone has a valid invitation, signing in with the invited email address and opening an app may also accept the invitation automatically. An Owner or Admin can check the Members page to confirm they have joined.
An email domain is the part after @ in an email address. Allowing your company’s domain lets people with those addresses use the join URL or Discover. See Domains and Join Policies for setup (Team plan and above). Members who join through domain-based joining receive the App User role.
To stop someone’s access to all apps in the workspace, for example when they leave the company, an Owner or Admin can select Deactivate on the Members page. To change only which apps they can use, for example after a department transfer, update their groups or app permissions.
Access checks may take time to reflect the change. Saving it does not necessarily stop all access immediately.
Disabling does not delete the account. Select Reactivate to restore access.
You cannot disable yourself or the last Owner. Admins cannot disable Owners.
Members automatically belong to the following groups based on their role. These are called system groups, and their membership cannot be edited manually.
Role
Automatic group membership
Owner
ownersdeveloperseveryone
Admin
adminsdeveloperseveryone
Developer
developerseveryone
App User
everyone
New apps grant view permission to everyone (all members) and manage permission to developers (Owners, Admins, and Developers). Manage permission includes view permission.
These group assignments determine who can use and manage an app. Changing a role also changes automatic group membership, which can affect app access. To limit an app to a specific team, see Share an App with Your Team.
First, check that you are signed in with the email address that was invited or registered. Ask an Owner or Admin to check your membership status, and someone who manages the app to check your app permissions.
Are you signed in with the registered account? If you use several Google or Microsoft accounts, check the email address.
Have you joined the workspace? Check the Members page. If membership is not complete, ask the person to open and accept a valid invitation link.
Has the member been disabled? Check Deactivated on the Members page.
Does the member have view or manage permission? Open the app’s Permissions tab and check the assignments for their groups.
Are IP restrictions blocking access? The denial page shows the IP address the user is connecting from. See IP Restrictions for where these restrictions apply.
You can limit an app to a specific team and share its URL. This guide gives access to the sales team and the people who manage the app.
Inviting members and creating groups requires the Owner or Admin role. Changing app permissions requires manage permission for that app. These features are available on all plans.
These settings determine who can open the app. Business rules such as “users can see only their own requests” or “only a supervisor can approve requests” must be implemented inside the app.
Open Members and choose the invite action. Enter the sales team’s email addresses and select App User. This role is for people who only use apps and does not consume Developers seats, the allowance for people who build and manage apps.
Each recipient opens the email link, signs in with the invited email address, and accepts the invitation. See Invite Members for details. You do not need to invite someone who has already joined.
If a recipient has a valid invitation, signing in with the invited address and opening an app may also accept it automatically. Before adding someone to a group, check the Members page to confirm they have joined the workspace.
For larger organizations, domain-based joining lets employees join without individual invitations on Team plans and above.
A group is a set of members you can assign app permissions to together. Open Members → Groups, create a group named Sales, and add the sales team members.
Each group has a display name and a key used in settings and commands. This example uses sales as the key. You cannot change the key after creation, but you can change the display name and membership.
Open the app’s Permissions tab. View permission lets someone open and use the app. Manage permission lets someone deploy the app and change its settings. Manage permission also includes viewing the app.
New apps grant view permission to everyone (all members) and manage permission to developers (Owners, Admins, and Developers). Adding Sales to the view groups alone leaves everyone in place, so everyone can still open the app.
Select Edit under View Permission.
Clear Everyone (everyone) and select Sales (sales).
Select Save under View Permission.
In this example, leave manage permission assigned to developers. View and manage permissions are saved separately, so save changes to view permission in the view section.
The sales team and members of developers can now open the app. App Users outside Sales cannot. If this app’s permissions were changed before, check for any other view or manage groups that remain assigned.
To change who can manage the app as well, see Groups and App Permissions. Anyone with manage permission can open the app even if they do not belong to a view group.
Access checks may take time to reflect saved changes. Once applied, the updated permissions also apply to users who are already signed in.
Copy the URL from the app page (for example, https://acme--sales-tool.keelson.run) and share it in chat or email. App Users cannot look up app URLs in the console, and invitation emails do not include app URLs.
Include a note such as “Sign in with the email address you were invited with” to help people who use several accounts.
5. Test with people who should and should not have access
Check that intended users can open the app and that someone with neither view nor manage permission cannot.
Check
Expected result
A Sales group member opens the URL
The app appears after login
An app manager outside the Sales group opens the URL
The app appears after login
An App User outside Sales, with no view or manage permission through another group, opens the URL
An access denied page appears after login
Someone who is not signed in opens the URL
Keelson’s login page appears; the app is not shown
If no one outside the intended audience is available to test, invite another email address of your own as an App User and test without adding it to Sales.
You can check the saved settings in the app list’s Access column. When view access is restricted, it shows a member count; hover over the count to see the group names. Use the checks above to verify actual access.
An AI agent that can use the CLI can create groups and configure app permissions. Give it the workspace, app name, email addresses of the people to include, and who should retain manage access. The same permissions are required as when using the console.
Configure sales-tool for the sales team and app managers. Add [email protected] and [email protected], who have already joined the workspace, to the Sales group (key: sales). Set view access to sales and manage access to developers. Afterward, tell me which groups have view and manage access.
After the agent finishes, test access with the accounts described in step 5.
CLI example
Create a group and add members who have already joined the workspace. These commands make the same changes as steps 2 and 3 in the console. You do not need to run them if you have already configured access in the console.
The settings above determine who can open the app. To show each person only their own requests, you can ask your AI agent:
Use the signed-in user’s identity provided by Keelson to show only their own requests. Prevent them from retrieving or updating someone else’s request, even if they supply its URL or ID, and verify that this protection works.
Alongside filtering the interface, your server-side code must check permissions when retrieving or updating data. Ask the agent to report how it verified that users cannot retrieve or change another person’s data.
Use the user ID supplied by Keelson (X-Keelson-User-Id) or group information for these checks. Do not identify a user solely from values sent by the browser. See Use the Signed-in User’s Identity for implementation details.
Workspace, app, and business permissions
Permission
Who configures it and where
Examples
Workspace management (roles)
Assigned by an Owner or Admin. Only Owners can assign or change the Owner role
Inviting members or handling billing
App view and manage permissions (view / manage)
Assigned to groups by someone with manage permission for that app
Opening the app, deploying it, or changing settings
Business permissions inside the app
Implemented by the app
Viewing only your own requests or approving requests as a supervisor
App manage permission is for people who deploy the app and change its settings. They are not necessarily the people who approve business requests. See Members and Roles for role details.
To let colleagues use your apps, invite their email addresses to the workspace. Choose a role based on whether they will only use apps or also build and update them.
Owners and Admins can invite members on all plans. The process is complete when the recipient accepts and you can confirm their membership on the Members page.
The allowance for people who build and manage apps is called Developers seats. App Users do not use these seats.
Person you are inviting
Role
Developers seat
Someone who uses apps
App User
Not used
Someone who builds and deploys apps
Developer
Used
Someone who also manages members
Admin
Used
Someone who shares responsibility for the workspace
Owner
Used
Admins cannot invite someone as an Owner. Developers and App Users cannot invite other users.
An invitation for a Developer, Admin, or Owner reserves one Developers seat when sent, even before the recipient accepts it. You cannot invite someone to these roles when the seat limit has been reached. You can invite any number of App Users regardless of available seats.
Open Members in the console and select Invite Member.
Enter email addresses (up to 50 at a time).
Choose a role.
Select Send Invitations.
Each recipient automatically receives an email with an invitation link. The results screen shown immediately afterward also lets you copy links to share directly. If you close that screen and later need a link, use Resend invitation on the Members page to issue and share a new one.
Invitations expire 3 days (72 hours) after creation.
Sign in with a Google or Microsoft account using the invited email address, or use a one-time code sent by email.
Signing in automatically accepts the invitation. If you are already signed in with the invited address, opening the email link joins the workspace immediately.
If you are signed in with a different email address, sign in again with the invited one.
The apps you can use after joining depend on app permissions. By default, every member can use every app.
The person sending the invitation should also send the app URL in chat or email. Invitation emails do not include app URLs.
App Users cannot access the console’s app list or management pages. After joining, they see a message directing them to ask the person who invited them for an app URL.
On the Members page, people who have not accepted their invitation appear as Pending; those who have joined appear as Active.
Revoke invalidates an invitation that has not been accepted.
Resend issues a new link for an invitation that has not been accepted. The old link becomes invalid, and the new one expires 3 days (72 hours) after resending.
For an expired or revoked invitation, create a new invitation for the same address.
Are you an Owner or Admin? Has the person already joined? For Developer, Admin, or Owner invitations, is a seat available?
The email does not arrive
Check spam. If it is still missing, copy the link from the results screen and share it. If that screen is closed, use Resend invitation to issue a new link
The recipient cannot accept
Are they signed in with the invited address? Has the invitation expired or been revoked?
The person joined but cannot open an app
Check app view permission, the signed-in account, and IP restrictions
Use groups to share apps with the right people: an accounting app with Accounting, or company announcements with everyone. You can separately choose who can update an app and change its settings.
Required permissions / plan: Owners and Admins can create groups and change membership. Anyone with manage permission for an app can change its permissions. Available on all plans.
Done when: An account with permission can open the app, and an account with neither view nor manage permission sees an access denied page.
Deploy the app, change settings, edit secrets, and change permissions. Includes view access
Permissions are assigned to groups, not individual users. Changing a group’s members also changes who can use or manage the apps assigned to that group.
Roles (Owner, Admin, Developer, and App User) determine what a member can do in the workspace. The view and manage permissions above determine app access. However, IP restrictions require an Owner or Admin; app manage permission alone does not allow changes to those settings.
By default, every workspace member can use the app, and members with development roles can manage it. Change these settings only for apps that need narrower access.
To restrict access to specific groups, remove view permission from everyone. Adding an Accounting group alone leaves everyone’s access in place. Also, manage includes viewing, so members of a manage group (developers by default) can open the app even if they are not in a view group.
System groups are assigned automatically by role and cannot be edited.
Key
Members
owners
Owners
admins
Admins
developers
Owners, Admins, and Developers
everyone
All members
admins does not include Owners. To give both Owners and Admins manage permission, select both owners and admins.
Custom groups represent business teams such as Accounting, Sales, or Store Staff. A group’s key (an identifier such as accounting; Japanese characters are also supported) cannot be changed after creation. Its display name and membership can be changed. A group cannot be deleted while it is assigned to an app.
Rules such as “only supervisors can approve requests” or “each department sees different information” must be implemented inside the app. These determine who can view or change business data, separately from permission to update the app itself.
For example, ask your AI agent: “Allow only the Accounting group to view the expense list.” After implementation, test what members inside and outside that group can see and do.
In your code, check business groups such as departments in attributes.groups, and check for manage in X-Keelson-User-App-Perms for app manage permission. See Use the Signed-in User’s Identity.
The app inventory’s access column shows Everyone when all members have access. For restricted apps, it shows the number of people and the groups with access.
When asking an AI agent to configure access, specify the group’s members and who should use or manage the app. This example shows an Owner or Admin granting view access to Accounting and manage access to Owners and Admins.
keelsonaccessset--appmy-app--viewnone# Clear view groups; people with manage permission can still use the app
The first commands list groups, create Accounting, and add its members. access show displays the app’s current assignments; the two access set commands apply the assignments shown above.
For larger organizations, you can let people with a company email address join without sending each person an invitation. Share a join link internally, or ask people to request access from the console’s Join or Create a Workspace page.
Owners and Admins configure joining and approve requests. This feature is available on Team plans and above.
Eligibility is determined by the email domain: the part after @. For example, the domain of [email protected] is example.com.
The Owner or Admin configuring this feature must have the same email domain as the workspace creator.
Join links cannot be enabled for public email domains such as Gmail or Outlook.com.
The same domain can be used in multiple workspaces, for example one per department.
Enabling a join link automatically registers the email domain of the Owner or Admin who enables it. You do not need to add the domain first. Check registered domains in Settings → Security → Access Control. If you add a domain manually, it must also match both your email domain and the workspace creator’s.
Copy the URL and share it in an internal chat or information page.
A person with an eligible address opens the link, signs in, and selects Join to become a member immediately. Check the Members page to confirm they have joined.
When the join link is enabled, the workspace also appears on the Join or Create a Workspace page for people signed in with the eligible email domain. Selecting Request to join sends a request to Owners and Admins.
Approve or reject requests under Pending Join Requests in Settings → Security. Submitting a request does not make someone a member; they cannot access apps until approved.
A join link is for joining the workspace. Share the URLs of the apps you want people to use separately, because App Users cannot see the console’s app list.
The apps they can use after joining depend on app permissions.
In the configuration details, joining methods are called join policies. The policy follows whether the join link is enabled; there is no separate policy selector.
Policy
Behavior
Invite only (default)
People join through individual invitations from an Owner or Admin
Join link
People with an eligible email domain can join through the link without approval. Requests from the Join or Create a Workspace page require administrator approval
Restrict an app to your office network or company VPN. In addition to checking login and app permissions, Keelson checks the network a user connects from. This is called IP restriction.
It applies to server-side web apps and the API portion of hybrid apps, where page delivery and server-side processing are separate. It does not apply to static sites, SPAs, static files in hybrid apps, or Media delivery URLs. See Scope for details.
Required permissions / plan: Creating access sources, setting workspace defaults, and configuring individual apps all require Owner or Admin. App manage permission alone is not enough. Available on all plans.
Done when: The app opens from an allowed network, and other networks receive an access denied page showing the IP address they are connecting from.
Configure IP restrictions using access sources: named collections of allowed network addresses. For example, an access source named “London office” can contain several IP addresses or address ranges. Ranges use CIDR notation, such as 203.0.113.0/24.
Create access sources in the workspace.
Choose the workspace’s default allowed sources. An empty selection means no IP restrictions.
For each app, choose whether to inherit the workspace default or use sources selected specifically for that app.
New apps inherit the default. Changing the workspace default updates all apps that inherit it. Apps with their own sources use those settings instead.
In the console, open Settings → Security → Allowed Sources and select + Add Source.
Source name: For example, “London office” or “VPN”.
IP / CIDR: One entry per line. Both IPv4 and IPv6 are supported. Enter individual addresses (203.0.113.10) or ranges (203.0.113.0/24). Use Add current IP to include the address you are connecting from.
Add to workspace default: Select this to include the new source in the default as soon as it is created.
To make an existing source part of the default, select it under Default Allowed Sources on the same Security page, then select Save default sources.
Ask your network administrator which fixed IP addresses or ranges to register: “I want this app to be accessible from the office and our company VPN.”
Home and mobile networks may change your IP address, which can prevent access even after you register it.
Applies the default access sources. If the default is empty, there are no IP restrictions
App-specific Sources
Replaces the default with one or more sources selected for this app
For an app without IP restrictions, configure it to inherit an empty default. Clearing the default removes IP restrictions from every app that inherits it. To keep restrictions on other apps, first switch those apps to their own access sources, then clear the default.
Changes take effect without a deployment, normally within two minutes. Changes made during an outage take effect after recovery.
When someone opens an app from a network that is not allowed, they see a page after login explaining that the app is available only from allowed access sources. It also shows their current IP address.
Check whether you need to connect to the company VPN. If the address needs to be added to an access source, send the displayed IP address to an Owner or Admin.
Restrictions apply to signed-in users accessing container-based web apps and the API portion of hybrid apps.
They do not apply to static sites, SPAs, static files in hybrid apps, or Media delivery URLs (/__keelson/media/<file-id>). Media checks login and app view permission. See Files and Media.
App tokens and webhooks use the allowed IPs configured on each token, rather than the workspace or app settings. See External Integrations.
Tools that AI clients call over MCP are also subject to the same workspace default and per-app settings. The IP address checked is that of the AI client’s server. Calls from claude.ai or ChatGPT come from each provider’s servers, not from the user’s computer, so they are denied as-is for apps with an IP restriction. See Use your app from AI clients (MCP).
Checks run in this order: login → app permissions → IP address. IP restrictions add a further requirement to login and app permission checks.
Requests from disallowed networks return HTTP 403. Browser page navigation receives an explanatory page; requests made within a page (fetch / XHR) and API calls receive JSON.
Check usage limits when choosing a plan or when you can no longer add or update an app. For prices, see the pricing page. For plan changes, cancellation, and invoices, see Billing and Subscription Management.
Start with these three questions when choosing a plan:
How many apps need to run at once? This is the number of web apps in use at the same time, not the total number of apps you have saved.
How many people will build or manage apps (Developers)? This allowance covers Owners, Admins, and Developers. App Users, who only use apps, do not count.
How much storage do you need? This covers saved files and other stored content. Database size is accounted for separately.
Maximum duration per run (scheduled run or background task attempt)
3 minutes
5 minutes
10 minutes
Builds per month
100
300
1,000
Build time per month
500 minutes
1,500 minutes
5,000 minutes
Concurrent builds
1
1
2
Daily backups retained
1
3
7
Point-in-time recovery (PITR) window
24 hours
7 days
14 days
Storage (workspace total)
10 GB
20 GB
50 GB
Database size (per database)
No fixed limit
No fixed limit
No fixed limit
Email recipients per month
500
2,000
10,000
Priority start (paid subscriptions)
—
Yes
Yes
Custom domains
—
—
Up to 10
Custom email sending domains
—
—
Up to 10 (20 allowed sender addresses each)
Access log history
30 days
90 days
180 days
App log history
7 days
14 days
30 days
The Enterprise plan is in preparation. Contact us if your requirements exceed these limits.
A build prepares an app to run. A scheduled job runs a task at specified times or intervals, configured using cron. A background task runs a command declared in tasks in a separate instance when the app enqueues it with the SDK; each started attempt, including a retry, counts as one Background Jobs run. Each retained daily backup is one saved backup version.
For email sent with the Email SDK, each To, CC, or BCC address counts as one recipient. The limit applies both to the workspace total and to each app’s total; sending stops when either is reached.
Unlimited saved apps on paid plans. Only the number that can run at once is capped. Trials have separate limits on owned apps and apps running at once; see Trial limits.
Authentication, access control, and IP restrictions.
Daily backups, manual snapshots (5 per app per day), PITR, and backup downloads.
Managed SQLite, with databases isolated by app or workspace.
A 14-day free trial, once per account. Starter and Plus can start without a card.
You can create up to 3 apps, excluding apps already deployed as static sites. Apps that have not been deployed and apps that use a server count toward this limit. Creating a fourth is rejected.
Up to 3 apps can run at once, regardless of plan. During the trial, this replaces the plan’s active-app allowance, and extra-app add-ons do not increase it. For Team, this reduces the allowance from 4 to 3.
You can run up to 7 builds in a rolling 24-hour period. This allowance does not reset all at once at midnight.
You can run up to 30 builds over the entire trial. This total does not reset at the start of a new month.
Build time and concurrent build limits remain those of your selected plan.
When you move to a paid subscription, the owned-app limit is removed, and your active-app allowance becomes the plan allowance plus any extra-app add-ons.
Apps that need a free slot cannot start or deploy. Running apps are unaffected. Opening a sleeping app when no slot is available shows an explanatory page (HTTP 503). An app without priority start releases its slot about 5 minutes after the last request
Developers
Invitations that reserve a Developers seat and role changes that use an additional seat are blocked. You can still invite App Users and make role changes that do not increase seat usage
Background Jobs runs
Remaining scheduled runs for the month are skipped, and new background tasks are rejected (TASK_MONTHLY_QUOTA_EXCEEDED). The allowance resets next month
Background Jobs runtime
Remaining scheduled runs for the month are skipped, and new background tasks are rejected (TASK_MONTHLY_QUOTA_EXCEEDED). A run or attempt that has already started is allowed to finish. The allowance resets next month
Scheduled job definitions, intervals, and duration; background task definitions and duration
Deployment is blocked if keelson.yaml exceeds your plan’s limits. Ask your AI agent to adjust the settings to the limit shown
Monthly builds / build time
New builds are rejected. Both allowances reset next month. The trial’s 30-build lifetime limit does not reset; moving to a paid plan is required to continue
Concurrent builds
New builds are rejected until a running build finishes and capacity becomes available
Email recipients
Further sends for the month are rejected (MONTHLY_QUOTA_EXCEEDED). The allowance resets next month
Custom email sending domains
Adding another domain or allowed sender address is rejected. Remove one you no longer use first
Storage
Apps do not stop immediately. Support will contact you about the overage
Database size
There is no fixed per-plan limit, but underlying technical limits still apply. App execution may be restricted or suspended if usage significantly exceeds normal business workloads or affects platform stability, other customers, or our reasonable operating costs. Emergency restrictions may be applied without prior notice
During a paid subscription, you can add capacity as needed. See Billing and Subscription Management for how to make changes and how they are billed. Add-ons are not available during the trial.
This is the number of slots web apps reserve to start and run. An ordinary app automatically releases its slot about 5 minutes after the last request. It keeps using the slot while requests continue.
Sleeping and suspended apps do not use a slot.
Apps with only scheduled jobs do not use a slot; jobs run separately from web apps.
Static sites do not use slots, and there is no limit on how many you can create.
Apps with priority start reserve a slot even without traffic. On paid subscriptions, this is available on Plus and above, for up to one fewer app than the total app-slot allowance. During the trial, all plans can enable it for up to 2 apps.
Developers seats are the allowance for people who build and manage apps. Each Owner, Admin, or Developer uses one seat. App Users do not count.
An unexpired invitation for an Owner, Admin, or Developer reserves one seat and counts toward usage even before acceptance. Revoke invitations you no longer need to release those reservations.
How seat counting differs by operation
When sending a new Owner, Admin, or Developer invitation, the limit is checked against existing members with these roles plus unexpired, unaccepted invitations. When accepting an invitation or changing a role to use an additional Developers seat, only existing members with these roles are counted. Changes that do not increase seat usage, such as changing the role of someone already using a seat, are not blocked by this limit.
Storage is the combined size of saved deployment files (artifacts), Files / Media SDK files, and snapshots. It is measured across the workspace and excludes logs. Managed SQLite size is accounted for separately and has no fixed per-plan limit.
Each request to your app, such as loading a page or saving data, has a time limit. If your app needs to do lengthy processing, ask your AI agent how to complete it within these limits.
Item
Limit
Time until the response starts (first response headers)
120 seconds; exceeding this returns a timeout (HTTP 504)
Total time for one request, including streaming responses
300 seconds
Time between chunks in a streaming response
120 seconds
Even if a timeout appears, the app may have completed an operation such as saving data. Ask your AI agent: “Make sure repeating the same action does not create duplicate requests or orders.”
Technically, an HTTP 504 does not necessarily mean the app’s processing was canceled. Keelson does not automatically retry POST requests, but users or clients may retry them. Your implementation therefore needs to avoid duplicate results when the same request is repeated.
For scheduled run and background task duration limits, see the plan table above. For build time limits, see the keelson.yaml Reference.
Use this guide to change your plan, check payment details and invoices, or end your use of Keelson. See Plans and Limits for usage limits and the pricing page for prices.
Required permissions: Owner or Admin. Only Owners can delete a workspace.
Where:Settings → Billing & Usage in the console. Workspace deletion is under Settings → Danger Zone.
Each account can use one 14-day free trial. Starter and Plus can start without a card. If you do not add a card before the trial ends, it ends automatically without a charge. Team requires a card to start the trial; cancel during the trial to avoid being charged.
If a card is registered, your first charge is on the trial’s end date. Subsequent charges occur monthly on your subscription’s billing date.
If an account that has already used its trial creates a second workspace, billing starts as soon as checkout is complete.
Currency is determined by the billing country: JPY including tax for Japan, and USD excluding tax elsewhere. It is fixed at the first subscription; contact support to change it later.
Payment on invoice and bank transfers are not supported.
The number of App Users does not affect your bill.
All trial plans have shared limits on owned apps, apps running at once, and builds. See Trial limits for the numbers and how usage is counted.
Status at trial end
What happens
Card registered
The subscription becomes paid and the first charge is made. The owned-app limit is removed, and the active-app allowance returns to the plan’s allowance
No card registered (Starter / Plus)
The trial ends automatically without a charge. New deployments, app starts, and scheduled runs are blocked. Apps and data are not deleted immediately; see Apps and data after your subscription ends
During the trial, both upgrades and downgrades take effect immediately without a charge.
Open Billing & Usage → Change plan. The amount is shown before you confirm.
Change
Takes effect
Billing
Upgrade
Immediately
Credit for the unused portion of the current plan is deducted from the charge for the remaining period on the new plan. This difference is charged when you change. The full new price applies from the next billing date
Downgrade
At the end of the current billing period
No prorated adjustment. You keep the current plan until the change takes effect
A downgrade appears as a scheduled change. You can cancel it before it takes effect.
A warning appears if current usage exceeds the new plan’s limits. If usage still exceeds them when the plan changes:
Apps running at once: Sleeping apps cannot start until usage falls within the allowance.
Developers seats: Invitations for Developers, Admins, or Owners, and promotions from App User to these roles, are blocked until usage is within the limit.
Scheduled runs: Remaining runs for the month are skipped.
Storage: This is a soft limit: apps do not stop immediately. A deployment warning appears and support contacts you. Sustained, substantial overages may lead to restrictions.
Custom domains (Team only): Existing registrations remain, but you cannot add or re-register domains.
Change add-ons (paid Team subscriptions and above)
Under Billing & Usage → Addons, choose quantities for extra app slots (+1), Developers seats (+1), and storage (+50 GB). Add-ons cannot be changed during the trial; you can add them after it ends. Increases are charged immediately to your registered payment method, prorated for the remaining subscription period. Reductions are credited toward future invoices.
Recent invoices appear under Invoices in Billing & Usage. Each row links to the Stripe invoice page and PDF.
To change your payment method, edit billing details, or see older invoices, select Manage billing. This opens the Stripe customer portal in another tab. You can cancel there too, but canceling from the Keelson console lets you review the end date and its impact before proceeding.
If a payment fails and becomes overdue, the service continues normally while Stripe retries. If payment still fails after retries end, the subscription is suspended and new deployments, app starts, and scheduled runs are blocked. Use Update payment method to update your details.
If you are leaving Keelson, export the data you need before your subscription ends. If deleting an app or workspace, export before deletion. After requesting cancellation, you can continue using the service until the subscription end date, giving you time to prepare.
Data
How to export it
Managed SQLite data
On the app page, open Restore points, choose a point within the recovery window, and download a SQLite dump. Downloads remain available during the grace period after the subscription ends
Create links using the file IDs saved by your app. A member with app view permission can sign in and download the files
Source code
From Git, or download it from an individual deployment in the Deploys tab
Environment variables and secrets
Secret values are not displayed in the console. If needed, retrieve them from where you originally managed them
Access logs and app logs
Export from the Logs tab (up to 5,000 lines for app logs)
The console and CLI do not provide file listing or downloads for Files / Media. You can ask your AI agent: “Build a page where I can list and export saved files. Implement downloads for Files and create links from saved IDs for Media. Allow only people with view permission for the app to use it.” Check that the downloaded files open correctly. See Files and Media for how these services differ.
Because apps cannot start after the subscription ends, finish exporting Files / Media before that date.
Open Billing & Usage → Cancel plan, review the end date and impact, and select Confirm cancel.
The plan remains active until the subscription end date and time, at the end of the current billing period. You can keep using it until then after requesting cancellation. There is no refund for the remaining period.
Before that date and time, select Reactivate to undo the cancellation request.
After that date and time, new deployments, app starts, and scheduled runs are blocked. Apps and data are not deleted immediately; you receive a deletion notice and grace period.
If your subscription ends after cancellation, your trial ends without a card, or your subscription is suspended for nonpayment, workspace apps can no longer start. They do not disappear immediately.
When eligible apps are identified, Owners and Admins receive a deletion notice in the console and by email. It includes the scheduled deletion date.
The grace period is 3 days for a workspace that has never made a payment, or 30 days for one with payment history. Service restrictions remain during this period, but you can sign in to the console and download database backups.
If the subscription becomes active during the grace period, the notice is canceled and the apps are kept. If the subscription has ended, select a plan to subscribe again. If it is suspended for nonpayment, use Update payment method to settle the outstanding balance. Complete this before the grace period ends to keep the apps.
After the grace period, apps are deleted automatically. As with user-approved deletion, database backups, Files, and a record of settings without secrets are retained separately from normal service use for 7 days. Restoration is not guaranteed.
Database backups can still be exported during the grace period. Files / Media must be exported before the subscription ends; see Export your data.
Check the notice for the scheduled deletion date. See also Article 20 of the Terms of Service for data handling after termination and exporting before your subscription ends.
Open Settings → Danger Zone → Delete this workspace. Only an Owner can do this, and you must enter the workspace name to confirm.
The workspace and all its apps are deleted, and members lose access. Deletion cannot be undone from the console.
Each app’s database backups, Files, and a record of settings without secrets are retained separately from normal service use for 7 days. Restoration is not guaranteed.
Deletion signs you out.
If a subscription is active, deletion also attempts to cancel it; you do not need to cancel first. However, cancellation can fail. After deletion, check that billing has stopped using the Stripe portal (open it through Manage billing before deleting) or invoice emails.
To delete your own Keelson account, open the account menu at the top right of the console, then Account settings → Security → Delete account. This deletes your Keelson account, not the Google or Microsoft account used to sign in. Apps and data in workspaces you belonged to remain.
This document is the canonical definition of the supported runtimes, constraints, and requirements for deploying an app to Keelson. Use it when deciding whether an app can be deployed.
For a quickstart and step-by-step instructions, see Deploy an app.
Verify deployment completion according to the app type, as well as the build result.
App type
Conditions to verify
Web app or hybrid
The build is complete, the web app process has started, and the health check has passed. An app URL has been issued and is reachable. A hybrid app must also serve its static files
Static site or SPA
Files have been uploaded and published and can be served at the app URL. No app process startup or web health check is required
Scheduled jobs only
The build is complete, and the schedules and job execution environment settings have been applied. Web process startup, health checks, and URL access do not apply
For web apps, a successful build is insufficient if startup or the health check fails. The app URL is https://<workspace-slug>--<app-slug>.keelson.run.
Scheduled jobs run on their configured schedules after deployment. A successful deployment does not establish that individual jobs have completed successfully; check their execution history and logs.
Each application is placed in one logical region when it is created. Defined
region keys are jp-tokyo (Japan) and us-oregon (US West); Keelson shows these
display names instead of cloud-provider region names. Only jp-tokyo is
currently open for new applications; us-oregon is defined but not yet offered.
For a new application, select a region with keelson deploy --new --region <region>, or set the top-level region field in keelson.yaml. The CLI option
takes priority over the file, and the file takes priority over the workspace’s
default region. An explicit selection of a region that is not yet offered (currently
us-oregon) is rejected rather than redirected. Without a selection the app
is placed in the workspace’s default region (Japan), so do not set region
unless the user asks for it.
The region is fixed after application creation. Deploying with another region
does not move an existing application and is rejected; create a new application
to use another region.
Keelson can run apps only on the following runtimes.
Runtime
Language
Intended use
python-slim
Python
Lightweight APIs, text processing, automation, and similar workloads
python-media
Python
Media processing; includes image and video libraries
node-slim
Node.js
Lightweight web apps, APIs, and similar workloads
node-media
Node.js
Media processing; includes image-processing libraries
go-slim
Go
Lightweight Go apps
go-media
Go
Go apps with media-processing dependencies
Select a runtime with the runtime field in keelson.yaml. If you are unsure, start with a -slim runtime and switch to -media when the app needs media-processing libraries.
A Dockerfile in the repository is ignored; it is not a reason to refuse a deploy. Keelson selects a runtime and starts the app with command. Specify the runtime and start command in keelson.yaml instead of a Dockerfile.
Keelson does not provide it. Apps run as UID 1000 and cannot create directories under the root-owned /
App directory (/workspace)
Cannot create entries directly in this directory; existing files and subdirectories may be writable depending on permissions
No
Source code and dependencies
/tmp
Yes, temporarily
No
Temporary files
Home directory (/home/appuser)
Yes in Python and Node.js runtimes
No
Runtime and tool caches and configuration
Other paths
Depends on ownership and permissions
No
Use /tmp for temporary files
Use /tmp for temporary files. Local writes are not persistent and are lost when the instance restarts or stops.
Keelson provides three forms of persistent storage: Managed SQLite (db.mode: libsql), the Files SDK for private app files, and the Media SDK for images and attachments served to users. None is available as a local file path. Apps may also connect to external databases or storage.
File-based SQLite is not persistent. File SQLite clients are detected during deployment and rejected unless declared for ephemeral use with db.local_sqlite.
The normal model is one process started by command.
systemd and daemon managers are not available.
Use crons for work that runs at set times, and tasks for work that continues after a request (Background tasks). workers has been removed, and declaring it causes the deployment to be rejected.
Pure language packages managed by the following package managers can be installed normally:
Python: pip (requirements.txt)
Node.js: npm (package.json)
Go: go mod (go.mod)
Keelson installs dependencies automatically while building the image by detecting requirements.txt, pyproject.toml, package.json, or go.mod. Use command only to start the app. If it includes pip install, npm install, or go build, the CLI preflight check stops deployment with command_installs_dependencies.
Build constraints: package-lock.json is the only supported Node lockfile. Only public registries are supported; private registries and authenticated installs are not. Go uses CGO_ENABLED=0. The build target is linux/amd64. Secrets are unavailable during builds. The time limit is 600 seconds.
A deployment mode is not chosen directly. It is derived automatically from the presence of command and assets. CLI and API output exposes the raw deploy_mode label; use the following table to interpret it.
Name
Raw deploy_mode
command
assets
Description
Web app
container
Present
Absent
Standard app deployment
Static site
edge-static
Absent
Present, without fallback
Static files only
SPA
edge-spa
Absent
Present, with fallback
Single-page app with fallback routing
Hybrid
hybrid
Present
Present; fallback required when type: web is explicit
Static files plus a backend API
Scheduled jobs only
container
Absent
Absent
Requires crons; runs scheduled jobs without starting a web server
Apps that run only scheduled jobs also use deploy_mode: container, but their startup and completion checks differ from web apps.
Static sites and SPAs require type: web and cannot use crons.
Some paths on the app’s host are used internally by Keelson or blocked from external access. Do not use the following as public app endpoints.
/__keelson and all paths beneath it are reserved for platform internals for every HTTP method. Keelson uses them for platform-served assets, file downloads, and internal endpoints. Do not define app routes at these paths.
Do not emit a __keelson directory at the root of the served build output. A build containing this reserved path is rejected with the error code reserved_path_conflict. Rename the directory and deploy again.
The platform responds to GET /health and GET /_health through the gateway. These GET requests do not reach the app even if it defines matching routes.
/api/webhooks/email and /api/webhooks/email-events are blocked from external access at the edge. Use different paths for app-defined webhooks.
/api/mcp and paths beginning with /api/mcp/ are reserved for MCP tool calls. In apps that run server-side code (including the server side of a hybrid app), requests to these paths that do not come through MCP return 404, even if keelson.yaml has no mcp: block. Do not put any route other than tool handlers (POST /api/mcp/<tool-name>) here. Statically served parts are not affected.
In apps with mcp.enabled: true, Keelson responds to /mcp, /.well-known/oauth-protected-resource, and /.well-known/oauth-protected-resource/mcp. These requests do not reach the app. See Use your app from AI clients (MCP).
Related rules: an auth.endpoints path cannot start with /__keelson; it must start with /api/external/ or /api/webhooks/. Reserved words also cannot be used as a slug.
Paths such as /assets/* (Vite’s default output), /files/*, /static/*, and /uploads/* are available to apps. /api/* is also available subject to the exceptions above and the authentication configuration rules.
The following table lists common frameworks’ default static paths. “Verified” means delivery has been checked in this repository; “Needs verification” means the result is inferred from default paths. For unverified frameworks, check the actual build output and delivery. This table is only about path collisions with /__keelson; it does not indicate whether a framework is supported.
Port the app must listen on. Read it; do not set it
TZ
Workspace time zone, detected automatically from the browser when the workspace is created
KEELSON_MODE
Marker indicating that the app is running on Keelson (keelson)
KEELSON_APP_ID
Internal app ID
KEELSON_WORKSPACE_ID
Internal workspace ID
KEELSON_TENANT_ID
Compatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_ID
Internal ID of the current deploy
The former tenant-named variable remains accepted as a compatibility alias. No
removal date is set.
With db.mode: libsql, Keelson also injects KEELSON_DB_URL / KEELSON_DB_AUTH_TOKEN (aliases: TURSO_DATABASE_URL / TURSO_AUTH_TOKEN). Names beginning with KEELSON_ are reserved and cannot be used in env or secrets.
Keelson apps run on Cloud Run and may start and stop during normal operation. On shutdown, the process receives SIGTERM and has 10 seconds before SIGKILL. Use a production server and handle graceful shutdown within that window.
Flask:app.run() starts the Werkzeug development server. It may remain behind a local __main__ entry point, but it must not serve a deployment, even with debug=False. Add gunicorn to requirements.txt and use:
Keelson provides 1 vCPU, so use one worker. A graceful timeout of 9 seconds fits within the 10-second SIGTERM window. Replace app:app with the module and application object for the project.
With DEBUG=False, Django does not serve static files itself. Add whitenoise to MIDDLEWARE, configure STATIC_ROOT, and ensure the deployed artifacts include the output of collectstatic.
Keep ALLOWED_HOSTS = ["*"]; restricting it can make health checks return HTTP 400.
Replace config.wsgi:application with the WSGI module for the project.
FastAPI / uvicorn: Bind to 0.0.0.0:$PORT and leave the worker count at its default of one:
Do not use --reload. File watching consumes memory and can start the app twice. Replace main:app with the module and application object for the project.
Next.js: Start the built application, not the development server:
command: "npm run start"
Set the package script to:
"start": "next start -p $PORT"
If package.json contains "start": "next dev", fix that script rather than working around it. next dev is not a production server.
Node.js: Keelson does not set NODE_ENV automatically. Declare production mode in keelson.yaml; without it, frameworks such as Express may return stack traces:
env:
NODE_ENV: "production"
Go: Start the built binary with command: "./app". Do not leave ListenAndServe without shutdown handling: use signal.NotifyContext and server.Shutdown to stop accepting new requests and drain in-flight requests within the 10-second window.
Apply fail-fast checks for missing production configuration only when ON_KEELSON is true. Do not use DEBUG as the platform check, because that can prevent the app from starting locally.
The response must start within 120 seconds or the request returns 504. Once streaming starts, the limits are 120 seconds without data and 300 seconds total
Scheduled job (cron)
1–600 seconds; default 300 seconds, reduced to the plan limit if it is lower
Apps can connect to external APIs and services by default.
The only exception is the SMTP ports (TCP 25, 465, 587, and 2525), which are always blocked. Send email through an email delivery service’s HTTPS API instead.
A scheduled job never fires, and no error is logged
The schedule lives inside the app process (an in-process scheduler). The app has scaled to zero by the time it is due
Declare it as a crons entry instead
The UI reports success, but the email or notification never arrives
Work deferred until after the response did not run
Finish it before responding, or enqueue it as a background task. Without background tasks, store the work in the database and process it periodically with crons; tell users it waits until the next scheduled run and may take longer depending on execution conditions
Check the runtime — Confirm that the app’s language appears in this specification’s supported runtimes.
Check dependencies — Confirm that native dependencies and system-library requirements fit within the constraints.
Check keelson.yaml — Confirm that the file exists and its configuration is valid. Create it if it is missing.
Check environment variables — Identify any missing environment variables or secrets the app requires.
Deploy — Use the CLI or Skill to run the deployment.
Verify completion — Follow the definition of a successful deployment for the app type. Check startup, health checks, and URL access for web apps; delivery at the URL for static sites and SPAs; and applied configuration for apps that run only scheduled jobs.
Handle failure — Inspect the logs and make changes where possible. If a constraint is violated, state the reason and stop.
keelson.yaml is the deployment configuration file at the root of your
project. It defines the application runtime, startup command, environment
variables, database mode, scheduled jobs, and static assets.
Keelson reads this file during deployment and uses it to determine how to
build and run the application. For a task-oriented introduction, see
Configure an app with keelson.yaml.
Application identifier; used when the application is created
workspace
string
No
null
Default workspace for project-scoped CLI commands. Prefer the workspace slug. Omit this field when your account has only one workspace because the CLI selects it automatically. An explicit --workspace overrides this value; apps list remains unfiltered
description
string
No
null
A short description of the application, up to 300 characters
icon
string
No
null
Image shown as the app’s icon in the console; a path relative to keelson.yaml. See icon
type
string
Conditional
null
App type; only web is accepted. Required for static sites and SPAs without a start command
Compatibility block that accepts only the deprecated disk_id field
assets
object
No
null
Static asset delivery configuration
health
object
No
null
Health check path used during deployment
secrets
object
No
{}
Secret declarations and requirements
auth
object
No
null
Endpoints that bypass interactive Keelson authentication
verify
string | list
No
[]
Extra paths checked after a deployment becomes healthy
region
string
No
null
Logical placement region for a new application
The retired top-level databases and workers keys are not valid, even when
their value is null or an empty list. Use db.mode: libsql for durable
relational data, tasks for work that runs after a request, and crons for
time-triggered work.
The former top-level tenant field remains accepted as a compatibility alias
for workspace. No removal date is set. If both fields are present, their raw
string values must match exactly.
This includes leading and trailing whitespace and letter case.
Unknown top-level keys such as build, port, and scaling, and unknown keys within objects, are rejected. This is not an extensible format for custom fields. null is rejected for crons, secrets, and storage; to omit one, leave out the key entirely.
Values starting with other symbols not covered by the exceptions below, such as ":memory:".
Quotes may be omitted in the following cases, except for the boolean and null words above:
Value type
Unquoted form allowed
Examples
Identifiers such as slug, runtime, or secret names
Starts with an ASCII letter or _
my-app, python-slim, OPENAI_API_KEY
Commands, descriptions, paths, and other strings
Starts with an ASCII letter, _, /, ./, or ../
python app.py, /health, ./app
A colon alone does not require quotes: description: https://example.com is valid unquoted. YAML syntax can still require quotes, for example around strings containing : (a colon followed by a space). Quoting does not change field-specific path or length constraints.
env values are an exception: every value requires quotes or a block scalar, regardless of its first character. See env.
An optional one- or two-sentence explanation of who the application is for
and what it does.
description: "Daily and weekly reports for the sales team, with automatic weekly summaries."
After trimming surrounding whitespace, the value must be at most 300 characters (Unicode code points). Exceeding this limit fails deployment. The description appears in the console and is applied only if the app’s description is still empty. Deployment does not overwrite a description edited in the console.
An optional image that the console shows as the app’s icon in the app list and
app header. Apps without an icon show their initial letter, and apps created
from a template show the template’s icon.
icon: ./assets/app-icon.png
The path is relative to the directory that contains keelson.yaml, not to the directory you run the command from. It must stay inside the project: absolute paths, paths that leave the project with .., backslashes, and symbolic links are rejected.
The image is uploaded with your source, so it must not be excluded from the archive (see Archive contents). For a static site without command, put it inside assets.dir.
Accepted images: PNG, JPEG, or WebP, at most 256 KB, and 32 to 2048 pixels on each side. The format is detected from the file’s contents, not its extension. SVG is not accepted.
keelson deploy (including --check) checks the image before it creates the app or uploads anything, and stops with the path and the reason: icon.path (missing, unreadable, symbolic link, or not uploaded), icon.format, icon.size, icon.dimensions, or icon.corrupt (the image header cannot be read).
The icon is applied when the deployment succeeds, and the CLI prints one Icon: line when it finishes (icon_outcome in --ndjson output):
icon_outcome
Meaning
applied
The image from keelson.yaml is now the app’s icon
unchanged
The app already uses this image
kept_console_icon
An icon uploaded in the console is kept; keelson.yaml does not replace it
kept_cleared
The icon was removed in the console, so deployments do not add it back
An icon set or removed in the console always takes precedence over keelson.yaml. To use the image from keelson.yaml after that, upload it in the app’s Settings in the console. Removing icon from keelson.yaml does not remove the app’s icon.
App icons are being rolled out. If a deployment is rejected with icon is not available yet, remove icon from keelson.yaml and deploy again.
The app type. Required for static sites and SPAs without a start command; optional otherwise.
type: web
The only accepted value is web. It enables static asset delivery through
assets and permits a deployment without a top-level command when static
assets are present.
Constraints:
type: web cannot be combined with crons.
Omit type for apps that combine a web app and scheduled jobs.
The execution environment. Required even for static-only deployments, but not used to build them. Static sites serve assets.dir exactly as built locally or in CI.
Keelson installs dependencies during the build for container and hybrid deployments. Static sites (type: web and assets, without command) skip this step. By default, only files under assets.dir and keelson.yaml are uploaded for static sites; see Archive contents. Use command only to start the app. Including pip install, npm install, or go build causes the CLI preflight check to stop deployment with command_installs_dependencies.
Runtime
Detected files
Build step
python-*
requirements.txt
python -m pip install --user -r requirements.txt
python-*
pyproject.toml with [project] or [build-system], only if requirements.txt is absent
python -m pip install --user .
node-*
package-lock.json / package.json
npm ci when a lockfile is present, or npm install, followed by npm run build --if-present
go-*
go.mod / go.sum
go mod download, then go build -o /workspace/app . (start it with command: "./app")
Build constraints:
If both requirements.txt and pyproject.toml exist, only requirements.txt is used. Dependencies and local packages declared only in pyproject.toml are not installed; include everything required in requirements.txt.
An installable Python pyproject.toml must be accompanied by a recognized
lockfile: requirements.txt, poetry.lock, uv.lock, or Pipfile.lock.
Node.js builds use npm only. Apps declaring external packages, including development, optional, or peer dependencies, require package-lock.json. Apps without external dependency declarations may omit it. If package.json exists without a lockfile, the build runs npm install. If only pnpm-lock.yaml or yarn.lock exists, preflight stops deployment with lockfile_unsupported; these files may remain alongside package-lock.json.
A Go module that declares third-party dependencies must include go.sum.
A standard-library-only module does not require it.
Dependencies must be available from public registries. Private registries,
git+ssh dependencies, and authenticated installs fail.
Defines non-secret environment variables as key-value pairs. All values are
strings.
Keelson sets PORT at runtime. A value declared in env is ignored.
Use quotes or a block scalar (| / >) for every value. An unquoted value is rejected
with env_value_not_string, and deployment does not start. Numbers and
booleans must also be quoted.
env:
NODE_ENV: "production"
DEBUG: "false"
LOG_LEVEL: "info"
Quoting is required because the CLI and API parse the file using YAML 1.2 and
YAML 1.1 implementations, respectively. Those versions can assign different
types to the same unquoted scalar. For example, K: 0o123 can become either "83" or "0o123". Quoting guarantees that both paths deploy the same string.
An unquoted key must start with a letter or underscore and contain only
letters, digits, and underscores. Ordinary names such as NODE_ENV and
DB_POOL can therefore be written without quotes.
The following words cannot be used as unquoted keys because YAML 1.1 may
interpret them as booleans or null:
Quote a key to use a hyphen, a reserved YAML word, or another otherwise
unsupported spelling.
env:
NODE_ENV: "production"
"MY-VAR": "x"
"yes": "x"
Keys beginning with KEELSON_, case-insensitively, are reserved for the
platform and cannot be set in env. Duplicate env declarations, duplicate
keys inside env, and YAML merge keys (<<) are also rejected.
Declare sensitive settings in secrets and set their values through the console or CLI. When a secret has the same name as an env entry, the secret takes precedence; changing that env value has no effect.
Keelson provisions Managed SQLite dedicated to the app and injects KEELSON_DB_URL / KEELSON_DB_AUTH_TOKEN (aliases: TURSO_DATABASE_URL / TURSO_AUTH_TOKEN)
none
Keelson does not manage a database; use this for database-free apps or an external database
Managed SQLite with mode: libsql requires no separate account or connection
configuration. It supports scale-to-zero and is the recommended option for
durable relational data.
For PostgreSQL, MySQL, or an independently managed libSQL service, select
mode: none and configure connection details as secrets. Keelson does not
inject credentials for an external database.
No mode makes file SQLite persistent (/data does not exist and cannot be created by the app). Code using file SQLite clients such as sqlite3 or better-sqlite3 is detected during deployment and rejected unless local_sqlite is declared.
db.migrate defines an application-owned migration command. Keelson runs it
once against the new image before switching rollout traffic.
db:
mode: libsql
migrate: "python migrate.py"
Valid only with mode: libsql; use a string.
Runs only for container and hybrid deployments. Static sites have no container, so a declared migration does not run.
Runs in the deployment image after the candidate revision passes its health check and before traffic switches. App startup code must work with the schema before migration.
A nonzero exit code fails the deployment with deploy.runtime.migration_failed, and the previous revision remains active.
Runs on every deployment, so make it idempotent, for example with CREATE TABLE IF NOT EXISTS and ON CONFLICT DO NOTHING.
Defines scheduled jobs. Keelson starts each job according to its schedule in a
separate instance from the web service.
crons:
- name: cleanup
description: "Delete expired sessions"
schedule: "0 3 * * *"
command: "python cleanup.py"
timeout: 60
enabled: true
Field
Type
Required
Default
Description
name
string
Yes
—
Unique job name, 1–63 lowercase letters, digits, or hyphens
schedule
string
Yes
—
Valid five-field cron expression
command
string | list
Yes
—
Command to execute
timeout
integer
No
300
Timeout in seconds, from 1 through 600
enabled
boolean
No
true
Whether Keelson schedules the job
description
string
No
—
One-line explanation of the job, shown in the console’s Scheduled jobs tab; see crons[].description below
Rules:
A configuration may contain at most 10 jobs. The plan may impose a lower
limit.
Job names must be unique.
crons[].description is display only. Leading and trailing whitespace is
removed, and an empty value counts as unset. A value longer than 100
characters after trimming is rejected with cron_description_too_long; it is
not truncated. Changing it does not change what runs.
type: web and crons cannot be combined.
A job runs in a separate instance; local files, including /tmp, are not shared with the web app. Store shared state in db.mode: libsql.
If the previous run is still active, Keelson skips the overlapping run.
Schedules are evaluated in the workspace time zone. This setting is detected
automatically from the browser when the workspace is created.
The absolute timeout limit is 600 seconds. The default is 300 seconds, reduced to the plan limit if that is lower.
See Plans and limits for job counts, minimum intervals, and timeout limits by plan.
Declares background tasks. When the app’s code enqueues a task by name with the
SDK, Keelson runs the declared command once in a separate instance from the web
service and retries a failed attempt automatically. Unlike crons, a task is
never started by the clock. See Background tasks
for enqueueing, writing the command, and making it safe to run twice.
command: "python app.py"
tasks:
- name: generate-pdf
description: "Generate the invoice PDF for an order"
command: "python generate_pdf.py"
timeout: 180
max_attempts: 5
Field
Type
Required
Default
Description
name
string
Yes
—
Task name, 1–63 lowercase letters, digits, or hyphens (same rule as crons[].name). Leading and trailing whitespace is removed and the name is lowercased, so Foo is accepted as foo
command
string | list
Yes
—
Command to execute. A string runs with /bin/sh -c; a list runs directly
timeout
integer
No
300
Time limit for one attempt in seconds, from 1 through 600
max_attempts
integer
No
3
Maximum number of attempts, including the first, from 1 through 5
description
string
No
—
One-line explanation of the task, shown in the console’s Background tasks tab; see tasks[].description below
Rules:
A configuration may contain at most 10 tasks on every plan.
Task names must be unique, and a task cannot have the same name as a job in
crons (task_name_conflicts_with_cron). Names are compared after removing
surrounding whitespace and lowercasing.
A configuration with tasks but neither command nor crons is rejected with
tasks_require_command_or_crons, because no code could enqueue the tasks.
This includes a static site with type: web and assets but no command.
type: web and tasks can be combined.
tasks[].description is display only. Leading and trailing whitespace is
removed, and an empty value counts as unset. A value longer than 100
characters after trimming is rejected with task_description_too_long; it is
not truncated. Changing it does not change what runs.
Unknown keys are rejected. Concurrency cannot be declared; Keelson applies the
plan’s value.
The absolute timeout limit is 600 seconds. An explicit timeout above the
plan limit is rejected with plan_task_timeout; the default of 300 seconds is
reduced to the plan limit if that is lower.
Each attempt receives one line of JSON on standard input with attempt_no,
payload, schema_version, task_id, and task_name. Exit code 0 means
success. A task runs in a separate instance; local files are not shared with
the web app.
Declares the app’s tools so that AI clients such as Claude and ChatGPT can use
the app over MCP. When a tool is called, Keelson calls the app’s
POST /api/mcp/<name>. Keelson handles login, the consent screen, and the
exchange with the AI client. See Use your app from AI clients (MCP)
for writing handlers, connecting, and limits.
command: "python app.py"
mcp:
enabled: true
instructions: "Look up stock levels and record incoming deliveries."
tools:
- name: search_items
description: "Search stock by item name."
access: read
input:
type: object
properties:
query:
type: string
required: [query]
- name: create_arrival
description: "Record one incoming delivery."
access: write
permission: manage
input:
type: object
properties:
item_id:
type: string
quantity:
type: integer
minimum: 1
required: [item_id, quantity]
Field
Type
Required
Default
Description
enabled
boolean
Yes
—
true publishes the tools. false keeps the declaration but stops calls (existing connections are not revoked)
instructions
string
No
null
Description of the tools as a whole, passed to the AI client. Up to 4096 characters
tools
list
Conditional
—
The tools. Required when enabled: true. When written, 1–40 entries regardless of enabled
file
string
No
—
Separate file holding instructions and tools; see mcp.file. Requires a recent Keelson CLI
tools[].name
string
Yes
—
Tool name matching ^[a-z][a-z0-9_]{0,63}$ (starts with a lowercase letter; lowercase letters, digits, and _ only; not normalized). Must be unique. The handler path is POST /api/mcp/<name>
tools[].description
string
Yes
—
Description shown to the AI client. 1–1024 characters, not only whitespace
tools[].access
string
Yes
—
read or write. write tools are shown separately on the consent screen, and users can approve read-only access
tools[].permission
string
No
view
view or manage. A manage tool can be used only by people with the app’s manage permission
tools[].input
object
Yes
—
JSON Schema (2020-12) for the arguments. The top level must be type: object
Rules:
Only apps that run server-side code (with command and without assets)
can declare mcp. Static sites, SPAs, and hybrid apps are rejected with
mcp is only supported for container apps.
Unknown keys under mcp and tools[] are rejected. The handler path is
fixed, so tools[].endpoint cannot be written.
Write input with JSON-compatible values only: at most 32 levels deep and
64 KB as JSON. Quote dates and any key that would otherwise not be read as a
string (yes, on, numbers, and so on).
Do not use $ref, $dynamicRef, $recursiveRef, or x-mcp-header in
input. $ref and x-mcp-header are rejected at deploy; $dynamicRef and
$recursiveRef pass deployment but every call fails.
YAML anchors, aliases, and merge keys (<<) cannot be used under mcp.
Even without mcp:, /api/mcp and paths beginning with /api/mcp/ are
reserved for MCP in apps that run server-side code (see
Reserved URL paths).
When there are many tools, instructions and tools can live in a separate
YAML file. keelson.yaml then contains only enabled and file.
command: "python app.py"
mcp:
enabled: true
file: mcp.yaml
# mcp.yaml (in the same folder as keelson.yaml)
instructions: "Look up stock levels and record incoming deliveries."
tools:
- name: search_items
description: "Search stock by item name."
access: read
input:
type: object
properties:
query:
type: string
required: [query]
At deploy time, the Keelson CLI inserts the separate file’s contents into
mcp in keelson.yaml before validating and uploading. Your local
keelson.yaml is not rewritten.
Only instructions and tools may appear at the top level of the separate
file (both optional). mcp.file cannot be combined with mcp.instructions or
mcp.tools.
The path is relative to the folder containing keelson.yaml and cannot point
outside it (including through .. or symbolic links). The extension must be
.yaml or .yml, and the file must be 4 MiB or smaller.
Errors inside the separate file are reported with the file name and location,
such as mcp.yaml: tools[3].input ....
Only a recent Keelson CLI reads mcp.file. Older CLIs reject the deployment
with mcp contains unsupported field(s): file; run keelson upgrade to get
the latest CLI. It cannot be used through paths that bypass the CLI.
workers for background workers or periodic drains has been retired. Any
top-level workers key is rejected with workers_not_supported, including an
empty list or null value.
For work that runs in response to a request, declare it in tasks and enqueue
it with the SDK (Background tasks).
For a periodic drain of pending work, define a scheduled job in crons. Use an interval allowed by the plan; process pending items and exit on each run.
storage accepts only the deprecated disk_id field. The value is ignored;
it does not enable persistent storage or automatic synchronization of /data.
Remove this block if it remains in your configuration.
Field
Type
Required
Default
Description
disk_id
string
No
null
Deprecated and ignored identifier; 1–32 lowercase letters, digits, or hyphens
Configures static asset delivery for static sites, SPAs, and hybrid
applications that combine static files with a backend API.
assets:
dir: dist
fallback: index.html
api: /api
Field
Type
Required
Default
Description
dir
string
Yes
—
Asset directory relative to the project root
static_dir
string
No
null
Deprecated alias of dir; if both are present, they must match
fallback
string
Conditional
null
SPA fallback, such as index.html, relative to dir. Required for hybrid apps with type: web
api
string
No
null (effective value /api in hybrid apps)
Path prefix forwarded to the backend. Defaults to /api when both command and assets are present
In hybrid apps, requests under api go to the backend; other requests serve static assets. The default prefix is /api; set this field only to use a different prefix.
Rules:
dir cannot contain ...
api must begin with /, cannot be / itself, and cannot be under /__keelson.
api requires a top-level command.
A hybrid app with type: web, assets, and command requires fallback. If type is omitted, configuration validation allows fallback to be omitted. Set a fallback such as index.html when serving an SPA.
Static sites (type: web and assets, without command) upload only files under assets.dir and keelson.yaml by default. Container and hybrid apps also include source needed for the build. The exclusions below apply to both.
Avoid assets.dir: . and equivalent paths such as ./. They make the project root the public asset directory, potentially exposing every file in the archive. The CLI warns before deployment but does not reject this setting.
When the CLI creates the deploy archive, it normally excludes paths containing
any of these names: .git, .venv, __pycache__, .pytest_cache,
node_modules, dist, build, .idea, .vscode, and .DS_Store. The
declared assets.dir and its ancestor directories are exempt, so build output
such as dist is included. Built-in exclusions still apply below assets.dir:
for example, dist/index.html is included but dist/node_modules/** is not.
.git is always excluded, as are symlinks, other non-regular files, and the file selected by --secrets-from-env-file.
Env-style files and directories are also excluded, including inside
assets.dir. A final path element is treated as an env name when it starts
with .env or ends with .env, case-insensitively. Examples include .env,
.env.production, .envrc, and .secrets.env. This rule applies whether or
not --secrets-from-env-file is used.
Create .keelsonignore in the project root for project-specific exclusions. .gitignore is not read.
Syntax
Meaning
One pattern per line
Exclude matching files or directories; the last matching line wins
A line beginning with #
Comment
/, trailing /, *, ?, **, [abc], [a-z]
Gitignore-like path patterns
Leading !
Re-include matching files or directories
Negation can deliberately re-include an env file such as !.env.production, but cannot override built-in exclusions such as .git, include .keelsonignore itself, or include the file selected by --secrets-from-env-file.
For a static app with assets and no string- or array-form command, the
archive includes only files under assets.dir and keelson.yaml by default;
ancestor directories remain traversable so the CLI can reach the asset
directory. Files outside that tree are not uploaded. A leading ! rule can
deliberately restore an outside file. To restore a directory and its contents,
use both !keep/ and !keep/**. Container and hybrid apps still archive the
source needed for their builds.
Run keelson deploy --check --json to inspect exclusions and files that may contain secrets.
Detected preview credentials or app tokens block upload.
If only webhook signing keys are detected, the CLI warns and continues.
Files whose names suggest sensitive content also produce warnings. Warnings do not exclude files; add exclusions to .keelsonignore as needed.
--check also validates major settings locally. However, the API validates unknown keys inside assets, crons, auth, and secrets, cron expression validity, and verify path formats. A deployment can still fail with deploy.config.invalid after --check passes.
Files suspected of containing secrets based on their names or other indicators
archive.embedded_credentials
Paths and types of credentials detected inside files; values are never shown
archive.unscanned_files
Files whose contents were not scanned, for example because of size limits
archive.unscanned_binary_count
Number of files classified as binary and not content-scanned
Files over 16 MiB, binary files, and files excluded from the archive are not content-scanned.
secret_like_files includes names such as credentials.json and *.pem, plus existing files selected by an earlier --secrets-from-env-file deployment that are not excluded this time. This includes names such as prod-secrets.txt that do not follow env-file naming rules.
To identify previously selected files, the CLI records their project-relative paths in .keelson-config/previous-secrets-files.json. The record contains no secret values and is automatically excluded from uploads.
JavaScript and CSS files with a clear content hash in the filename, such as
index-B7hK2mQ1.js, are cached for one year because the content at that URL is
expected never to change. Keelson recognizes a final 8–64 character segment
made of ASCII letters, digits, and underscores, containing both letters and
digits, as a hash. HTML, unhashed files such
as style.css and app.js, images, and all other assets are checked for updates
on every visit. When a file has not changed, its ETag makes this a lightweight
validation request.
After redeploying a static site (edge-static / edge-spa), new files can take up to about 60 seconds to reach the edge (measured 1–57 seconds, median about 30). Wait 60 seconds before concluding a file is missing.
Declares the app’s secrets and required combinations. Set values separately through the console, CLI, or an env file during deployment; do not put values here.
Environment variable name: starts with an uppercase letter or _, contains only A-Z0-9_, and is at most 255 characters. KEELSON_ names are reserved
items[].description
string
No
""
Human-readable purpose of the secret
required
list
No
[]
Rules checked against configured secrets
required[].any_of
list
Conditional
null
Nonempty list of names; at least one must be set
required[].all_of
list
Conditional
null
Nonempty list of names; all must be set
required[].message
string
No
""
Guidance displayed when requirements are unmet
Each required entry must contain exactly one of any_of or all_of. The
selected list must be non-empty and may reference only names declared in
items. An optional message supplies remediation text.
Names are normalized to uppercase when values are saved, while required matching is case-sensitive. If you declare name: api_key, its value is saved as API_KEY, so the requirement does not match. Use uppercase names from the start. Names with hyphens, such as MY-KEY, can be declared but cannot have values set.
For a new app, keelson deploy --new --secrets-from-env-file <path> creates the app, registers secrets, and starts its first deployment in one command. Changes to an existing app’s secrets take effect on the next deployment.
Configures application endpoints that are intended for external systems, such
as webhook senders, and therefore do not use the normal interactive Keelson
login flow.
auth:
endpoints:
- /api/webhooks/stripe
- path: /api/external/status
methods:
- GET
auth.endpoints is required when auth is present and must be a non-empty
list. Each entry is either a path string or an object with path and optional
methods.
Endpoint paths must be unique and start with /api/external/ or
/api/webhooks/. Paths under /__keelson are reserved. If methods is
provided, it must be a non-empty list.
Paths use prefix matching: /api/external/status also matches /api/external/status-extra. Methods are case-insensitive; omitting them allows every method. methods only applies to /api/external/, not /api/webhooks/. Restrict webhook methods in app code.
Bypassing interactive login does not bypass Keelson authentication. Declaring an endpoint alone is insufficient; callers must supply Keelson-issued credentials.
Path
Required credentials
/api/external/...
App token with the api scope in Authorization: Bearer
/api/webhooks/...
App token with the webhook scope in X-Webhook-Secret, or a URL registered with the external service in the form /api/webhooks/<secret>/...
Issue tokens with keelson apps tokens create. See External integrations for URL construction. A standard Stripe webhook carrying only a Stripe signature, or a client carrying only an app-defined API key, is rejected before reaching the app.
Requests to these paths carry X-Keelson-User-Id as an empty string (the header is always set, empty when there is no interactive user). Treat an empty value as “no user”. In addition to Keelson credentials, verify webhook signatures or API keys in the app.
Adds paths to deployment verification. By default, only / is checked.
verify:
- /dashboard
- /settings
Use this to check entry points that cannot be reached from /, such as pages accessible only through client-side routing.
A single path may be a string or a one-item list.
At most 20 paths are allowed after trimming whitespace and removing duplicates. Each must start with /, must not start with //, and must not contain .. path components or control characters.
Container and hybrid deployments fetch paths from the candidate revision through the public host. Static sites and SPAs resolve paths against the release manifest before publication.
A declared path must return 2xx or 3xx. A 404, 401, 403, 405, or any other
response outside those ranges fails verification with
verification_declared_path_unreachable, for both container/hybrid apps and
static/SPA apps.
Write only paths that do not require the app’s own user login. Verification
requests identify the candidate revision but do not carry an app user identity,
so an app-level 401 or 403 fails the deploy.
/ remains a tolerant default even if it also appears in verify; an API-only
app may legitimately return 404 there.
This differs from health.path: the health probe goes directly to
the candidate revision and accepts 404 as proof that the process started.
verify checks whether a declared public path is actually servable, so 404 does
not pass.
Optionally selects the placement region when creating a new application.
region: jp-tokyo
Use a Keelson logical region key, not a cloud-provider region identifier:
Key
Display name
New apps
jp-tokyo
Japan
Available (default)
us-oregon
US West
Not yet offered (an explicit selection is rejected)
For a new application, selection priority is the CLI --region option, then
region in keelson.yaml, then the workspace default. An unknown or
not-yet-available explicit selection is rejected instead of falling back to
another region.
An application’s region is fixed when the application is created. A later
deploy with a different region is rejected and cannot move the application;
create a new application to use another region.
Deployment mode is derived from the presence of command, assets, and
fallback; it is not selected directly. CLI and API output such as
keelson status --json returns the raw deploy_mode label.
Static sites and SPAs also require type: web.
The web application enqueues generate-pdf with the SDK when it receives a
request. Keelson runs the task’s command in an instance separate from the web
application. See Background tasks.
To run work after a request without background tasks, store it in the database
and process it with a scheduled job. Work waits until the next run. To start
work right after a request, use
background tasks instead.
The web application writes pending rows to Managed SQLite. The cron processes
those rows from a separate instance, so both processes share state through
db.mode: libsql, not through the local filesystem.
Include an app token with the webhook scope in the URL registered with Stripe: https://<host>/api/webhooks/<token>/stripe. methods does not apply to webhooks; reject methods other than POST in app code if needed.
Remove the install or build step; dependencies are installed automatically during the image build
A dependency manifest is missing (missing_dependency_manifest)
For Go, put go.mod at the project root. For Python or Node.js, add the appropriate manifest if the app uses external libraries; a standard-library-only app does not need one
runtime and the language in command differ (runtime_command_mismatch)
Choose the matching runtime or fix the start command
Build fails with reserved_path_conflict
Rename the __keelson directory at the root of the build output
Environment variables
This page lists the environment variables Keelson sets in app containers and the variables used to run the Keelson SDK locally.
Do not declare or override automatically supplied variables in env in keelson.yaml; use the values Keelson provides. Names beginning with KEELSON_ are reserved and cannot be used in env or secrets.
Port the app listens on. Read this value; do not set it
TZ
Workspace time zone (for example, Asia/Tokyo)
KEELSON_MODE
keelson on Keelson. Usually unset during local development; local also selects local storage for the Files / Media SDK and local synchronous runs for the Tasks SDK
KEELSON_APP_ID
Internal app ID
KEELSON_WORKSPACE_ID
Internal workspace ID
KEELSON_TENANT_ID
Compatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_ID
Internal ID of the current deploy
KEELSON_APP_URL
App URL (https://<ws>--<app>.keelson.run). Use when an absolute URL is required. Set when Keelson can generate the app’s public hostname
KEELSON_DIRECTORY_BASE_URL
Directory API base URL used by the Identity SDK. Set to the same value as KEELSON_APP_URL, under the same condition
The former tenant-named variable remains accepted as a compatibility alias. No
removal date is set.
Container deployments with Media enabled (automatic for standard deployments)
Upload destination and authentication token used by the Media SDK
KEELSON_MEDIA_URL_PREFIX
Same condition as the Media SDK upload destination and token
Media delivery URL prefix (/__keelson/media/)
KEELSON_DIRECTORY_TOKEN
After keelson apps directory enable and redeployment
Directory API token used by the Identity SDK. Handle only on the server
KEELSON_EMAIL_API_URL / KEELSON_EMAIL_TOKEN
Container deployments (automatic for standard deployments)
Endpoint and authentication token the Email SDK uses to send email. Handle only on the server
KEELSON_EMAIL_WEBHOOK_SECRET
Same condition as KEELSON_EMAIL_TOKEN
Key for verifying the signature of email delivery result notifications. Pass it to the Email SDK’s verification function
KEELSON_TASKS_BASE_URL
Set in web, scheduled job, and task containers while Keelson accepts background tasks (whether or not the app declares tasks)
URL the Tasks SDK uses to enqueue tasks and read their status. You do not set it yourself
Static sites and SPAs (assets without command) have no container, so no runtime environment variables are set. They cannot use the Files or Media SDKs. Apps that run only scheduled jobs also omit command, but their jobs run in containers and can use SDK environment variables.
Set these in your local environment as needed to run the SDKs during development. Do not register them in env in keelson.yaml or as Keelson secrets.
Variable
Description
KEELSON_MODE=local
Use local storage with the Files / Media SDKs. With the Tasks SDK, enqueue runs the command synchronously on your machine through the CLI’s keelson dev task run. Both also work this way when this is unset in a standard local development environment
KEELSON_FILES_DIR
Local storage location for the Files SDK (default: ./.keelson/files)
MEDIA_DIR
Local storage location for the Media SDK (default: ./media)
KEELSON_LOCAL_MODE=1
Make the Identity SDK return test data without calling the API. Not needed when you start through keelson dev serve (Run your app locally)
KEELSON_LOCAL_USERS_FILE
User list file the Identity SDK reads when KEELSON_LOCAL_MODE=1 (default: ./.keelson/dev-users.json)
Test workspace ID, workspace role, and app ID returned by the Identity SDK
The former KEELSON_LOCAL_TENANT_ID and KEELSON_LOCAL_TENANT_ROLE names remain
accepted as compatibility aliases. If both old and new names are set, the names containing WORKSPACE take precedence. No removal date is set.
This page lists the main keelson CLI commands and the behavior scripts and agents need to account for. An AI agent typically runs them through an Agent Skill, so you do not need to memorize them all. Use keelson <command> --help for command-specific help.
See the Quickstart to install the CLI. Run keelson upgrade to update it.
In the examples below, <...> is a value to supply and [...] marks optional arguments or flags. Use durations such as 30s or 5m for <duration> and integers for <n>.
These flags are shared by several commands, but support varies. Commands such as login, logout, install-agent, and dev ... accept only some of them. app curl does not accept --json or --quiet.
Flag
Description
--app <slug>
Target app; resolved from keelson.yaml in the current directory when omitted. Required for access show, access set, apps tokens create, apps tokens rotate, and feedback
--workspace <slug|name|id>
Target workspace by slug, name, or ID. Use the slug or ID if names are ambiguous
--json
Output the result as JSON. Use this from scripts and agents
--quiet
Suppress explanations and progress; output depends on the command
--timeout <duration>
API call timeout; default 15 seconds, with exceptions below
--retry <n>
Retry count for temporary failures; default 0, with exceptions below
--no-browser
Show a login or approval URL without opening a browser. Supported by login, apps delete, db apply, and snapshots restore
Workspace selection follows this order: --workspace, workspace in keelson.yaml, then the saved default workspace. If no workspace is selected, the CLI automatically selects your only workspace; if you belong to several, supply --workspace. The exception is apps list, which lists all workspaces when the flag is omitted.
With --quiet, secrets set prints one line per secret name and deploys list prints one line per deployment ID. A successful deploy --check prints nothing.
Timeout and retry exceptions:
apps duplicate defaults to a 120-second timeout.
The app request made by app curl defaults to a 130-second timeout. Temporary GET / HEAD failures are retried up to twice; use --retry 0 to disable retries.
App creation in deploy --new retries temporary failures up to three times even with --retry 0. This retry count cannot be set below three. A value above three sets the higher limit.
preview and app curl do not accept --no-browser, even when browser approval is required. In non-interactive execution, they print an approval URL without opening a browser. The same applies to preview --json.
When a command fails with --json, standard output contains an error object. Its code identifies the error, message explains it, and hint describes what to do. If retryable is false, do not retry automatically; follow hint.
Older names such as --tenant and keelson tenants list are still accepted. See Command aliases.
Delete saved login credentials on this device and invalidate the server-side session. --all-devices logs out every device. Tokens supplied through KEELSON_TOKEN are not revoked
keelson whoami
Show the signed-in user and currently selected workspace. Use workspaces list --json to reliably obtain a workspace slug
keelson workspaces list [--query <text>] [--exact]
List or search your workspaces. Pass the slug from --json output to --workspace
keelson doctor [--fix-path]
Check PATH, login, and Skill status. --fix-path adds PATH to the shell configuration file
Send feedback about the CLI and the Skill as JSON on standard input. See Feedback
As an alternative to keelson install-agent, install the Skill from the public repository using the Claude Code plugin commands /plugin marketplace add keelsonhq/skills followed by /plugin install keelson@keelson-skills, or use npx skills add keelsonhq/skills for other agents. Use only one installation method; combining them can create two copies of the Skill or cause one installation to overwrite the other. These alternatives install only the Skill, so you must also install the CLI and sign in.
A Skill installed as a Claude Code plugin is not updated by keelson login; it follows Claude Code plugin updates instead. The plugin is outside the location checked by keelson doctor, so you can ignore its “not configured” result and its suggestion to run install-agent for Claude Code.
Create an app and deploy it for the first time. Register env file values as secrets and exclude that file from the archive
keelson deploy --check [--json]
Validate configuration and show archive contents, exclusions, and potentially sensitive files without uploading. This local check cannot determine whether you will exceed plan limits
keelson deploy --ndjson --yes
Stream progress as one JSON object per line for scripts
keelson status [deploy_id] [--wait] [--max-wait <duration>]
Show the specified deployment’s status, or the app’s latest deployment if no ID is supplied
keelson diagnose [deploy_id]
Diagnose a failed deploy (with an ID), including stored startup logs when startup failed, or the running app (without an ID)
keelson deploys list [--limit <n>] [--offset <n>]
Show deployment history
keelson deploys download <deploy_id> [-o <path>]
Download the source archive used for a deployment, within its retention period
keelson rollback [deploy_id] [--yes]
Return to the previous successful deployment or a specified successful deployment
Waits for completion; default 30 minutes, configurable with --watch-timeout
keelson deploy --yes in a pipe, CI, or other non-interactive environment
Returns when accepted. Use --yes --watch to wait
--json
Returns when accepted; cannot be combined with --watch
--ndjson
Waits for completion; cannot be combined with --no-watch
--no-watch
Returns when accepted, even in an interactive terminal
In normal output mode, non-interactive execution requires --yes; omitting it produces confirmation_input_unavailable. --json, --ndjson, and --quiet run without asking for confirmation.
Reaching the watch timeout stops monitoring, but does not stop the server-side deployment. Continue checking with status --wait.
status --wait waits up to 50 seconds by default. Set a different limit with --max-wait <duration>. At the limit it returns the latest status it retrieved. If status retrieval succeeds, the exit code is 0 even when the deployment is still running.
Scripts must inspect terminal and status in --json output, rather than relying on the exit code alone. terminal: false means the deployment is unfinished. Success requires terminal: true and status: "completed".
A previous successful revision may still be serving even when the latest deployment failed. Use keelson app info to check the app currently serving traffic.
Only successful deployments with a container image can be rollback targets. To revert a static site, redeploy its previous source. Rollback skips the build but runs db.migrate again.
rollback returns when accepted. Pass the returned deployment ID to keelson status <deploy_id> --wait --json and inspect terminal and status. --yes is required with --json or non-interactive execution.
NDJSON contains progress lines with fields such as stage and status. During a health check, for example, stage is health_check and status is progress. A progress line’s status does not determine the outcome of the whole deployment.
If monitoring starts, a final line contains result, with the value success or failed.
If the command fails before monitoring starts, it emits an error line without a result line. Invalid configuration may produce only an error line. Failures after registering secrets or uploading the archive may also have earlier progress lines.
Check the process exit code too. A nonzero code means the CLI operation failed. A monitoring timeout also counts as failure, although deployment may still be running on the server.
Issue an expiring token for authenticated access to the app
keelson app info / start / stop / restart
Show app information, start, suspend, or restart the app. restart waits for completion; use --no-watch to return when accepted
With app curl --data, also specify --method POST, PUT, PATCH, or DELETE. --form defaults to POST when --method is omitted. Repeat --form or --header for multiple fields or headers.
The response body is written to standard output. -i also writes the HTTP status and response headers to standard error. Temporary GET / HEAD failures are retried up to twice by default; use --retry 0 to disable retries.
If someone other than the person who deployed the currently serving revision runs preview or app curl, browser approval is required even for reads. Approval alone does not issue the token or send the request. Within 30 minutes of approval, rerun the same command with --confirmation <id>. This differs from the approval flow in Operations that require browser approval.
(--previous is not supported on the current runtime and returns 422; crashed-instance output is included in the normal stream) Show the app’s standard output and standard error
Show deployment progress and stored failure details, including startup logs when the app exited during startup. Successful build output is not retained; the command explains when no logs are available
--since accepts durations such as 1h or 1d12h, from 1 second through 30 days.
--json returns one JSON object containing log arrays and other fields. The log location depends on the command.
Command
Logs in JSON
logs app / logs cron
entries: objects containing the timestamp, severity, message, and other fields
logs access
entries: objects containing the timestamp, HTTP status, path, and other fields
logs deploy
logs: progress log objects. failure_logs and startup_logs: arrays of strings
keelson apps list [--workspace <slug>] [--query <text>] [--exact]
List apps. Omitting --workspace includes all your workspaces. JSON results use an apps array when the flag is supplied and a workspaces array when omitted
A duplicate is created from the source app’s latest successful deploy. The duplicate is a separate app with its own URL and deploy history.
Item
What happens
Application code
Copied from the latest successful deploy
Configuration (keelson.yaml)
Copied, with the duplicate’s new app identity
Scheduled job definitions
Copied, but created disabled after the duplicate’s deploy succeeds. --enable-schedule enables jobs unless their configuration explicitly sets enabled: false
App-level secrets
Not copied by default. Use --copy-secrets to copy them
Workspace-level secrets
Not copied, but they remain available because workspace-level secrets are provided to every app in the workspace
Platform-managed credentials
New credentials are issued for the duplicate
Managed database contents
Not copied; the duplicate gets a new, empty database. The CLI has no option to copy database contents
Deploy history
Not copied; the duplicate starts with a new deploy based on the source app’s latest successful deploy
keelson crons runs list [--cron <name>] [--limit <n>] [--offset <n>]
Show execution history
keelson dev cron run <name> [--file <path>] [--dir <path>]
Run a scheduled job from keelson.yaml once locally
keelson dev task run <name> [--payload <JSON> | --payload -] [--file <path>] [--dir <path>] [--json]
Run a background task from keelson.yaml once locally, synchronously, with no retry. --payload is the task’s payload as JSON (- reads it from standard input; omitted means null). With --json, standard output carries one result object. The SDK uses this command when enqueue runs locally. See Background tasks
Start your app’s server with <command> and relay requests to it, adding the same user information headers as Keelson. Switch users with the switch page (/__keelson/dev) or the X-Keelson-Dev-As header. --port is the port to listen on (default 5173), --users is the user list file (default ./.keelson/dev-users.json, or four built-in users if absent), and --as is the user at startup. The app receives a free port in PORT, and {port} in <command> is replaced with it. Does not start in a deployed environment. See Run your app locally
keelson dev serve --check
Exit with code 0 without starting anything. Use it to check whether this CLI supports dev serve
Run SQL against Managed SQLite; omit the file or use - for standard input. Browser approval is required if the DB already contains schema objects. A first-time DB with no tables, indexes, views, or triggers needs no approval
keelson snapshots list
List restore points and running or recent exports, including successful and failed exports
keelson snapshots create
Create a manual snapshot
keelson snapshots download <id> [-o <file>]
Download a backup. Without -o, only prints a download URL and writes no file
keelson snapshots export --at <RFC3339>
Create a point-in-time export (PITR), then retrieve it with download after completion
keelson snapshots restore <id>
Restore a snapshot. Browser approval is required. The restore can be undone within 72 hours
keelson snapshots restore-status
Check the progress and result of the latest restore
keelson db revoke-tokens [app] [--yes]
Revoke DB authentication tokens (destructive). Prompts in the terminal; non-interactive use requires --yes. The DB is temporarily unavailable until the new revision takes over
keelson db recover [app] / credential-status [app]
keelson groups create <key> [--name <text>] [--description <text>] [--if-not-exists]
Create a custom group
keelson groups members list <key>
List group members
keelson groups members add|remove <key> <email>...
Change group membership for existing members; does not send invitations
keelson access show --app <slug>
Show the app’s view and manage groups
keelson access set --app <slug> [--view <key>]... [--manage <key>]... [--allow-self-lockout]
Set groups with view and manage permissions
access set replaces all bindings for each permission type you specify. Supplying only --view leaves manage permissions unchanged. Use --view none to clear view groups. Manage groups cannot be empty.
Repeat the flag for multiple groups. For example, to replace view groups with sales and engineering:
keelson feedback --app <slug> [--deploy <id>] < feedback.json sends feedback about the CLI and the Skill. The payload is one JSON object on standard input. Unknown fields are rejected.
Field
Description
verdict (required)
Object with required score (integer 1–10) and summary (1–1,000 characters)
friction
Up to 20 objects with step, command, output_quote, expected, lost_minutes, and recovered
Deleting an app, running SQL against a database with existing schema objects, and restoring a snapshot require browser approval. The command displays an approval URL (confirmation_required). Approving in the browser executes the operation on the server; do not rerun the command. Approval expires after 15 minutes.
Behavior after the URL is displayed depends on execution mode:
Interactive terminal: waits for approval and execution by default. Use --wait=false to return without waiting.
--json or a non-interactive terminal: returns without waiting by default. Use --wait to wait.
preview and app curl use the separate rerun flow described under Verify. rollback, db revoke-tokens, and domains remove require terminal confirmation rather than browser approval; non-interactive execution needs --yes.
Directory containing credentials.json and config.json
KEELSON_API_BASE_URL / APP_DOMAIN_BASE
Override the API endpoint and app domain; normally unnecessary
Error codes and resolutions
This page covers common Keelson errors, where to find them, and how to resolve them. It includes CLI errors, deployment failures shown in the console, and errors when accessing deployed apps.
For CLI errors, inspect code, message, and, when present, hint. For deployment failures in the CLI or API, inspect failure_code and failure_hint. Follow any guidance returned with the error before this page: it is specific to that situation. Include these details when asking an AI agent to investigate or fix a problem.
This is not an exhaustive list. For unlisted codes, read the returned message and guidance.
The location and response format depend on where the operation runs.
Location
How to inspect the error
Console
Open the app overview or deployment history to see the failure reason and logs
CLI
Read the terminal error. With --json, standard output contains error with fields such as code, message, hint, and retryable
Deployment failure (keelson status / keelson diagnose)
Inspect failure_code and failure_hint
Keelson API
Messages are generally in detail; input validation can return an array. Some approval, snapshot, validation, and manage-permission errors use error with code, message, hint, and other fields
App access denied at the gateway
Browsers receive an explanation page; fetch / XHR receives JSON such as {"detail":"ip-restricted"}. Check X-Keelson-Auth-Error for the reason
No available app slot or suspended app at the gateway
HTTP 503 with x-keelson-platform-error: capacity-full or app-suspended. Capacity errors also include X-Keelson-Error-Code: CAPACITY_FULL
Missing URL, suspended app, or reserved path at the edge
HTTP 404 / 410 / 503 / 403. Browsers receive an explanation page and API clients receive plain text; some reserved paths also return plain text to browsers. Inspect x-keelson-platform-error. These requests do not reach the gateway and have no X-Keelson-Auth-Error
Files / Media delivery
Inspect x-keelson-error. Some codes also return JSON containing error, hint, and doc
Example CLI error with --json:
{
"error": {
"code": "not_logged_in",
"message": "Not logged in.",
"hint": "Run `keelson login` or set KEELSON_TOKEN, then retry the command.",
"retryable": false
}
}
retryable indicates whether retrying the same operation may succeed. HTTP status alone is insufficient: a 503 may have retryable: false, and some build-related 429 errors, such as the cumulative trial allowance, do not clear by waiting.
These findings are reported before upload and can also be checked with
keelson deploy --check. Errors stop the deployment; warnings do not. A
missing dependency manifest is an error for Go, but only a warning for Python
and Node.js.
A dependency manifest declares the libraries an app uses, for example go.mod, requirements.txt, or package.json. Go requires go.mod even without external libraries.
Code
Cause
Resolution
missing_dependency_manifest
No dependency manifest for the selected runtime
For Go, add go.mod to the project root. For Python or Node.js, add a manifest if the app uses external libraries; no dependency manifest is required for a standard-library-only app
command_installs_dependencies
command contains pip install / npm install / go build
Remove it and leave only the start command
runtime_command_mismatch
The languages of runtime and command do not match (for example, node with python-slim)
Correct either one
assets_dir_empty
assets.dir is missing or empty, or the path uses the wrong letter case
Run the build and check the path
assets_fallback_missing
The assets.fallback file is not in assets.dir
Check the build output
db_wiring_fail (warning)
db.mode conflicts with database connection code; rules cover a production connection that does not read KEELSON_DB_URL, a local-development file: fallback left on the production path, and declaration mismatches. Keeping file:local.db as the fallback when the environment variable is absent is fine
Follow hint. A common fix is to use a libSQL client that reads KEELSON_DB_URL
Invalid YAML, a db.mode other than libsql / none, db.migrate used with none, env beginning with KEELSON_, a path under /__keelson, an unknown runtime, more than 10 crons, or another configuration error
Correct the issue described in message
config_missing_field
A required field such as slug, runtime, or db is missing; static sites also require type and assets
Quote values or use block scalars. Quote ambiguous keys such as on, yes, or numbers. Remove duplicates and expand merge keys; quoting does not make duplicates or merge keys valid
description_too_long
description is longer than 300 characters
Shorten it
workers_not_supported
workers: is present
Remove it. Use tasks for work that runs in response to a request and crons for time-triggered work
DB_DATABASES_REMOVED
databases: is present
Remove it. Use db.mode: libsql for persistent data
SQLITE_LOCAL_SQLITE_INVALID_PATH
A db.local_sqlite.paths value is outside /tmp/
Use /tmp/ or :memory:
CRON_TIMEOUT_EXCEEDS_STANDARD_LIMIT
A crons[].timeout value exceeds 600 seconds
Reduce it to 600 seconds or less, or split the operation
Job count, interval, or timeout violates plan limits
Reduce the count, increase the interval, shorten the timeout, or choose a plan that supports the required settings
tasks_require_command_or_crons
tasks is declared, but there is neither command nor crons, so no code can enqueue the tasks
Add the command of the web app that enqueues the tasks, or a job in crons
task_name_conflicts_with_cron
A tasks[].name matches a crons[].name after removing surrounding whitespace and lowercasing
Rename the task or the job
TASK_TIMEOUT_EXCEEDS_STANDARD_LIMIT
A tasks[].timeout value exceeds 600 seconds
Reduce it to 600 seconds or less, or split the work into smaller tasks
plan_task_timeout
An explicit tasks[].timeout exceeds the plan’s limit per run
Shorten the timeout, or choose a plan with a higher limit. An omitted timeout is reduced to the plan limit instead of being rejected
ARTIFACT_SQLITE_FILE_DRIVER
A file-based SQLite client such as sqlite3 or better-sqlite3 was detected
Migrate to a libSQL client. For temporary use, declare it in db.local_sqlite
A __keelson directory at the root of the build output fails the build. The failure_code is deploy.build.failed, and the build log contains reserved_path_conflict. Rename the directory.
Scheduled work is registered inside the app process
It will not run while the app sleeps. Extract a standalone command and declare it in crons in keelson.yaml
ARTIFACT_EXEC_BACKGROUND_TASKS
Work runs after the response is returned
await short external calls and work required for request success before returning the response. Declare work that continues after the response in tasks and enqueue it with the SDK (Background tasks)
ARTIFACT_EXEC_SET_INTERVAL
Server-side code uses setInterval
No change is needed for work such as cache refreshes where missed runs are harmless. Move work requiring ongoing scheduled execution to crons
Check the app overview or deployment history in the console for the reason and logs. In keelson status, keelson diagnose, or the API, inspect failure_code and failure_hint. The table also includes errors that prevent deployment from being accepted.
Code
Cause
Resolution
deploy.config.invalid
Invalid keelson.yaml content
Correct the reported key and deploy again
deploy.config.secret_missing
A secret declared in secrets.required is not set
Set every secret with keelson secrets set <KEY> (the value is read from standard input), then deploy again
deploy.artifact.invalid
Uploaded archive failed validation, for example because a required lockfile is missing
Fix the files or code identified in the error
deploy.artifact.assets_invalid
Files declared by assets.dir or assets.fallback are missing from the upload
Build locally and redeploy
deploy.build.failed
Dependency resolution, a build script, or a reserved-path collision caused a build failure
Read the end of the build output with keelson logs deploy <deploy_id>
deploy.build.timed_out
Build exceeded its time limit
Remove unnecessary dependencies or move expensive preprocessing out of the build
deploy.build.image_too_large
The image is too large
Remove unnecessary dependencies or files
deploy.runtime.start_failed
The app exited during startup or the container did not reach the running state
Read the stored startup logs with keelson logs deploy <deploy_id>, then verify PORT and 0.0.0.0
deploy.runtime.health_check_failed
/ or health.path did not return an accepted response (2xx / 3xx / 404) within the startup and health verification time limit. This includes exiting during startup or repeatedly returning 401 / 403 / 5xx
Read stored startup logs with keelson logs deploy <deploy_id>; check the listening port and health.path
deploy.runtime.migration_failed
db.migrate exited nonzero; the previous revision continues serving
Fix the migration and redeploy. Partially applied database changes are not automatically undone
deploy.verify.failed
A verify path is unreachable, or JavaScript / CSS Content-Type does not match
Check paths and build output. Details appear as verification_* codes in failure_logs
deploy.plan.limit_exceeded
A plan limit was exceeded
Identify the limit in failure_hint, adjust settings or usage, or choose a plan with the required limit
deploy.plan.cron_count_exceeded
The app declares more crons than the plan allows
Reduce the number of crons or upgrade the plan
deploy.plan.cron_interval_too_short
A cron fires more often than the plan’s minimum interval
Increase the interval or upgrade the plan
deploy.plan.schedule_timeout_exceeded
An explicit crons[].timeout exceeds the plan’s ceiling
Lower the timeout or upgrade the plan
deploy.app.suspended
Keelson has restricted the app or workspace
Contact support. A user or administrator cannot remove this restriction by resuming the app
deploy.app.operation_in_progress
The deploy could not start because another operation was in progress for this app
Wait for the other operation to finish, then deploy again
deploy.platform.temporarily_unavailable
Temporary outage
Wait and retry up to three additional times. If it still fails, contact support with deploy_id
deploy.platform.error
Platform error
Retrying will not resolve it. Contact support with deploy_id
deploy.cancelled
Deployment was cancelled
Redeploy if needed
deploy_in_progress (409)
Another deploy or app operation is in progress
Wait for the operation to finish, then run the same command again
The limit for build count, build time, concurrent builds, or consecutive failures was exceeded
The message states when the restriction clears. For consecutive failures, fix the cause and wait until the stated time. The trial build allowance is cumulative and does not reset by waiting; change plans in that case
saved_config_unsupported (409)
The keelson.yaml for a redeploy or rollback does not conform to the current schema
Inspect executions[].failure_code in keelson crons runs list --json. Use keelson logs cron to investigate execution logs too.
cron.failed.* means a job started and failed; dispatch_failed means it could not be started. cron.skipped.* means a scheduled run was skipped because its execution conditions were not met.
These errors are returned by the Tasks SDK when the app calls enqueue or get. Each SDK raises or returns one error type (Python and Node.js TasksError, Go *tasks.Error) with code, status, and message. Branch on code; do not depend on the wording of message. See Background tasks for how to use tasks.
Code
Meaning
When it occurs
Resolution
TASK_INVALID_REQUEST
The task name is empty, idempotency_key is not 1–128 printable ASCII characters, the payload cannot be serialized to JSON, or the request is otherwise malformed
enqueue, on Keelson and locally
Fix the arguments. Retrying the same call does not help
TASK_NOT_DECLARED
The task name is not in the currently deployed tasks (locally: not in keelson.yaml), including a name removed from tasks
enqueue, on Keelson and locally
Declare the task in tasks and deploy, or correct the name
TASK_NOT_FOUND
The task_id does not exist or belongs to another app. Locally, get knows only tasks enqueued in the same process
get, on Keelson and locally
Check the task_id your app saved
TASKS_UNAVAILABLE
Task intake is closed, or the app is suspended, quarantined, or being deleted. Retrying does not help
enqueue, on Keelson only
Check the app’s status in the console. For a restricted app, contact support
TASK_PAYLOAD_TOO_LARGE
The request body, payload and idempotency_key serialized as JSON, exceeds 65,536 bytes
enqueue, on Keelson and locally
Store large input in the database or with the Files SDK and pass its ID in payload
TASK_BACKLOG_LIMIT_EXCEEDED
The app already has 1,000 tasks waiting to start (queued)
enqueue, on Keelson only
Wait until waiting tasks start, or enqueue less often. Tasks already enqueued are not affected
TASK_MONTHLY_QUOTA_EXCEEDED
The workspace’s monthly Background Jobs allowance cannot cover the first attempt
enqueue, on Keelson only
Intake can reopen within the same month when reserved allowance is returned, on a plan upgrade, or at the start of the next UTC month. Do not assume it stays closed until next month. The SDK does not retry it automatically
TASKS_UNAVAILABLE_TRANSIENT
A temporary outage: 502 / 503 / 504 without an error body, a connection failure, or the 15-second timeout per call
enqueue and get, on Keelson only
The SDK retries up to 3 attempts in total. enqueue retries only with an idempotency_key; add one so a retry does not create a duplicate task
TASKS_UNAUTHORIZED
The runtime API rejected the identity token (401)
enqueue and get, on Keelson only
Contact support if it persists
TASKS_FORBIDDEN
Cloud Run rejected the call (403). Right after the first deploy that declares tasks, the permission can take a few minutes to take effect
enqueue and get, on Keelson only
Wait a few minutes after the first deploy with tasks, then try again. Contact support if it persists
TASKS_SERVER_ERROR
Another 5xx response without an error body
enqueue and get, on Keelson only
Retry later. Use an idempotency_key when retrying enqueue
TASKS_HTTP_ERROR
Another unsuccessful response without an error body
enqueue and get, on Keelson only
Contact support if it persists
TASKS_UNEXPECTED_RESPONSE
A successful response did not have the documented shape
enqueue and get, on Keelson only
Update the SDK. Contact support if it persists
TASKS_IDENTITY_TOKEN_ERROR
No identity token could be obtained from the metadata server
enqueue and get, on Keelson only
Contact support if it persists
TASKS_NOT_CONFIGURED
The SDK could not decide between Keelson and local execution, for example because KEELSON_MODE is unknown, or KEELSON_MODE=keelson is set without KEELSON_TASKS_BASE_URL or KEELSON_APP_ID
enqueue and get
On Keelson, do not override KEELSON_MODE or the other KEELSON_ variables. Locally, set KEELSON_MODE=local or remove the Keelson variables. See Environment variables
TASKS_LOCAL_CLI_NOT_FOUND
keelson is not on PATH
enqueue, locally only
Install the CLI with https://keelson.dev/install.sh
TASKS_LOCAL_CLI_FAILED
The CLI could not run the task, for example because it is too old to have keelson dev task run, or it was interrupted
enqueue, locally only
Run keelson upgrade. Run keelson dev task run <name> --payload '<JSON>' to see the error directly
A command that exits nonzero or times out is not an enqueue error. The task ends as failed, and get reports the reason in last_failure_code.
get returns last_failure_code for a failed task, and for a task that is waiting to be retried. The app’s Background Tasks tab in the console shows the standard error of the last attempt.
Code
Meaning
Attempt counted
Retried
exit_nonzero
The command exited with a nonzero code
Yes
Yes, while attempts remain
timed_out
The attempt exceeded timeout and Keelson stopped the command
Yes
Yes, while attempts remain
no_completion
The attempt ended without reporting its result before the deadline
Yes
Yes, while attempts remain
delivery_failed
The attempt could not start within one hour
No
No
manifest_unresolvable
The task waited across two or more deploys, and the declaration it was enqueued with is no longer available
No
No
quota_exhausted
A retry, or an attempt starting in a new month, could not reserve the monthly Background Jobs allowance
No
No
There is no manual rerun. To run the work again, enqueue it again from the app.
Supply --workspace <slug>; search with keelson workspaces list --query <text>
no_workspaces
No available workspace
Create a workspace in the console
app_not_found
The app was not found
Check the slug. For a new app, use --new
app_manage_required
The app exists, but you do not have manage permission
Ask a current manager to add you to a group with manage permission. Workspace OWNERs and ADMINs can add themselves with keelson groups members add <group> <email>
app_deleting
The app is being deleted
Wait for completion
forbidden
You do not have permission. CONSOLE_ONLY_OPERATION identifies an operation limited to the console
Check the role and app permissions
confirmation_expired / confirmation_rejected
Approval expired or was rejected; expiry is 15 minutes for delete / SQL / restore and 30 minutes for preview / app curl
Run the command again
deploy_failed
Deploy failed
Run keelson diagnose <deploy_id>
sql_failed
SQL in db apply failed and was rolled back
Correct the SQL
usage (access set; API 409 detail.reason is last_manage_binding_removed, no_effective_manager, or self_lockout)
Change would remove all manage groups, all effective managers, or your own permission
Retain a manage group. --allow-self-lockout only permits removing yourself; it cannot override the requirement for manage groups and effective managers
transient
Temporary network error
Specify a retry count, for example --retry 3
Legacy code compatibility: Some workspace-related errors include the former tenant code in error.aliases. No removal date is set.
meta.skill_outdated, or a skill_outdated warning from keelson doctor, means the installed Skill is older than the CLI. This warning alone does not fail the command. Update with keelson install-agent --yes.
These messages appear when opening a deployed app. They are not error screens for the console itself.
Display
State
Resolution
Login screen
Not logged in
Log in with an account registered in the workspace
”Access denied” (403)
The user is not a member, is blocked, or lacks app view permission
Ask an administrator to register the member or grant permission
”This application is only available from approved networks” (403)
IP controls denied access; the page shows the source IP
Ask an administrator whether the displayed IP should be allowed
”The app cannot start right now because no app slot is available.” (503)
Concurrent app limit reached
A slot generally becomes available after another app occupying one receives no access for about 5 minutes. If none becomes available, ask an administrator about stopping an app or changing plans
”Starting the app”
Waking from sleep
Wait for the page to change automatically
”This application has been suspended” (503)
Suspended by an administrator or restricted by Keelson
Ask the administrator why. Contact support if the administrator cannot resume it
”App not found” (404)
No app matches the URL: incorrect URL, suspended or deleted app, or a newly created app awaiting publication
Check the URL and app status. For a newly created app, wait briefly
”This URL is unavailable” (410)
App URL changed; the old URL is reserved for 30 days
Ask the administrator for the current URL
For API or XHR requests denied by the gateway, the JSON detail and X-Keelson-Auth-Error header contain reasons such as ip-restricted or app-view-denied. Capacity and suspension errors use x-keelson-platform-error with capacity-full or app-suspended.
These errors occur during requests or connections to the app. In particular, a WebSocket 501 is not itself shown as a browser explanation page; what users see depends on the app’s error handling.
HTTP status
Cause
Action for the app developer
504
App did not start returning response headers within 120 seconds; this is not the total streaming duration limit
Reduce work before the response starts. Use crons if the work can run at a scheduled time or interval
The detailed reason is returned in X-Keelson-Auth-Error; for compatibility, the JSON detail remains machine-forbidden or webhook-forbidden for IP allowlist denials.
At the gateway, a missing app route and a token belonging to another app both produce machine-forbidden or webhook-forbidden. If the edge cannot find the URL route before the request reaches the gateway, the response is 404 instead.
X-Keelson-Auth-Error
Cause
invalid-app-token
The token is invalid or revoked
machine-scope-denied
Token lacks the api scope
machine-endpoint-denied
The path or method is not declared in auth.endpoints
machine-forbidden / webhook-forbidden
Gateway cannot find the app route, or the token belongs to another app
machine-ip-not-allowed
The request came from outside the app token’s allowed IP ranges
invalid-webhook-secret
The Webhook secret does not match
webhook-scope-denied
The token does not have the webhook scope
webhook-ip-not-allowed
The request came from outside the Webhook token’s allowed IP ranges
Webhooks sent to platform-reserved paths such as /api/webhooks/email are rejected at the edge before reaching the gateway, with HTTP 403 and a plain-text body. The header is x-keelson-platform-error: auth-webhook-endpoint-blocked, not X-Keelson-Auth-Error.
CLI usage telemetry
Keelson uses CLI usage telemetry to understand which workflows succeed, find reliability problems, and improve the CLI. It is enabled by default.
Exit code and, when available, the published failure code
Command duration
Names of flags used, but not their values
Whether the command ran in an interactive terminal (TTY)
CLI version
Type of agent that launched the CLI; it is recorded as unknown when it cannot be determined
For list commands, only whether the result contained zero, one, or multiple items
A unique identifier for each event, used to discard duplicates
The date and time the command ran
A session identifier. This is a random value created on the device and is replaced after 30 minutes without a recorded command. Stored event rows do not contain account, workspace, or app identifiers. Events are sent using your signed-in credentials, and submission counts are tracked per account, so the transmission itself is not anonymous.
To disable telemetry through the environment instead, set:
Terminal window
KEELSON_NO_TELEMETRY=1
The environment variable takes precedence over the saved preference. While it is set, keelson telemetry on cannot enable telemetry. Disabling telemetry deletes pending event records, and you can check the current state at any time with keelson telemetry status.
Disabling telemetry stops event recording and sending and removes the X-Keelson-Client header. The regular User-Agent header still includes the CLI version as keelson-cli/<version>.
Immediately before the first telemetry send, the CLI displays a one-line notice when it is running in an interactive terminal. It does not display this notice in non-interactive automation.
keelson feedback is a separate mechanism for sharing feedback you provide. It sends only when explicitly run and feedback sharing is enabled. Feedback sharing is disabled by default. Turning CLI usage telemetry on or off does not change the feedback-sharing setting.