Skip to content
Console →
Website →
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.

Start here

Refer to these as needed

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.”

https://keelson.dev/docs/reference/deploy-spec.txt

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 Keelson CLI lets you sign in and deploy from your terminal. Select your operating system and run the install command.

Terminal window
curl -fsSL https://keelson.dev/install.sh | sh
If the keelson command is not found

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"

Next, run the login command. Your browser opens so you can sign in with your Keelson account.

Terminal window
keelson login
  1. Download the sample app (ZIP).
  2. Extract the ZIP file.
  3. Open the extracted keelson-hello folder in Claude Code, Codex, or Cursor. If you are not using an AI agent, open a terminal in that folder.
See the sample code
main.py
from html import escape
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
email = escape(request.headers.get("X-Keelson-User-Email") or "Guest")
return f"""
<!doctype html>
<html lang="en"><body style="font-family: sans-serif; padding: 2rem">
<h1>Hello, {email}</h1>
<p>This app is running on Keelson.</p>
</body></html>
"""
requirements.txt
fastapi
uvicorn
keelson.yaml
slug: hello
runtime: python-slim
command: "uvicorn main:app --host 0.0.0.0 --port $PORT"
db:
mode: none

The app reads the signed-in user’s email address from a header supplied by Keelson and displays it. It contains no authentication code.

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.

Terminal window
keelson install-agent claude-code --global

With --global, the skill is available in your other projects too. Without it, the skill is installed only 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
npx skills add keelsonhq/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.

Send this request in your agent’s chat:

Deploy this app to Keelson.

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
keelson deploy --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
keelson status <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.

  1. Open the app URL in your browser. Sign in to Keelson if prompted. Check that the page shows “Hello, …” and the text below it.
  2. Open the same URL in a private or incognito window where you are not signed in to Keelson. You should see the Keelson login page instead of the app.

Seeing “Guest” does not mean you are signed out. The sample displays “Guest” when your login method does not provide an email address to the app.

You now have an app at its own URL that only signed-in users can open. It also appears in the Keelson console.

Core concepts

Keelson keeps your internal apps and members together in a workspace. You do not need to rebuild login or member management every time you add an app.

This page introduces the key terms and explains which features Keelson provides and which your app implements.

TermMeaning
WorkspaceA place to manage members and apps together
AppA business tool you deploy and use, such as a sales tracker or daily report app
MemberA person who belongs to a workspace
GroupA collection of members, such as a department or team, that you can assign access to together
DeploySend 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.

You manage members and groups in the workspace, then assign permissions to groups for each app.

PermissionWhat it allows
View (view)Open and use the app
Manage (manage)Deploy the app and change its settings and secrets

View permission lets someone access and use an app. Whether they can create or edit data inside it depends on the app’s code.

New apps start with these permissions:

GroupMembersApp permission
everyoneAll workspace membersView
developersMembers with the Owner, Admin, or Developer roleManage, which includes view

To restrict an app to a specific team, see Share an app with your team.

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

Section titled “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:

FeatureKeelson providesYour app implements
Pages and business logicAn app URL and runtime environmentForms, reports, approval requests, and other features
Login and permissionsLogin, member management, and checks on who can open an appBusiness rules, such as showing only the current user’s data
Data storageManaged SQLite and the Files / Media SDKCode that connects to storage and reads and writes data
API keys and other secretsStorage and delivery of secrets to the appCode that uses the supplied keys to connect to external services
Scheduled jobsStarting work at configured times or intervalsCode that performs one run of the task and exits
OperationsLogs, app listings, and Managed SQLite backup and restoreLogging 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.

See Environment variables and secrets.

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.

See When apps run and Supported apps and constraints for implementation details.

FAQ

Find answers to common questions about supported apps, pricing, access, and data. Follow the links in each answer for detailed instructions.

Can I deploy an app built with Lovable, Bolt, or v0?

Section titled “Can I deploy an app built with Lovable, Bolt, or v0?”

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.

Which plan fits two developers and 50 app users?

Section titled “Which plan fits two developers and 50 app users?”

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?

Section titled “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?

Section titled “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?

Section titled “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?

Section titled “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.

What happens after a trial or subscription ends?

Section titled “What happens after a trial or subscription ends?”

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.

See Change plans for other limits.

Building Apps


Supported apps and constraints

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.

AppStatusNotes
Python web apps (FastAPI, Flask, and others)SupportedStart on the port specified by PORT. Use gunicorn for Flask
Node.js web apps (Express, Hono, Next.js, and others)SupportedStart on the port specified by PORT. Use next start for Next.js
Go web apps (net/http, Gin, and others)SupportedKeelson builds the app and starts it with ./app
Prebuilt static sites / SPAs (React, Vue, Svelte, and others)SupportedPoint assets to the build output
Static frontend + API server (hybrid)SupportedSet the API path with assets.api
Scheduled jobs onlySupportedSet crons alongside the shared required fields; omit the web server command
Connections to external PostgreSQL / MySQL / libSQLSupportedSet db.mode: none. Supply connection details through secrets
Calls to external APIs (OpenAI, Slack, Google Sheets, and others)SupportedOutbound 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 requiredSwitch 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 filesChanges requiredUse Managed SQLite or the Files SDK / Media SDK. Local data written during execution does not survive restarts
In-app timers or background threadsChanges requiredMove work that runs at set times to scheduled jobs, and enqueue work that continues after a request as a background task (How apps run)
DjangoExternal database onlyConnect 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)
StreamlitUnsupportedIt requires WebSocket and currently does not work (Framework notes)
GradioUnder evaluationVersion 4 and later are being evaluated. Operation is not currently guaranteed
Ruby / PHP / Java / Rust / .NET / ElixirUnsupportedOnly Python, Node.js, and Go runtimes are available
Adding apt packages through a DockerfileUnsupportedNo root access. A -media runtime may cover image or video requirements
Apps requiring continuously running processes, daemons, or WebSocket serversUnsupportedIncompatible with the execution model, which stops idle apps
Running your own Redis / Kafka / database serverUnsupportedConnecting to an external service is allowed
GPU workloadsUnsupported

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.

For apps built with other tools, such as Lovable, Bolt, or v0, see Bring an app from another tool.

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.

RuntimeLanguage versionBuild behavior
python-slim / python-mediaPython 3.12Automatically installs dependencies from requirements.txt or pyproject.toml
node-slim / node-mediaNode.js 24Automatically installs dependencies from package.json and package-lock.json. Runs npm run build if defined
go-slim / go-mediaGo 1.26Builds 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.

See the keelson.yaml reference for dependency file requirements and build constraints.

ItemBehavior
OS / CPULinux / x86_64
Permissions and system packagesRuns as a non-root user. sudo and apt-get are unavailable
Web server startupListen on 0.0.0.0 and the port specified by PORT
Data storageLocal files are not persistent. Use Managed SQLite or the Files / Media SDK for data you need to keep
ProcessingRequest handling, background tasks, and scheduled jobs are supported. See How apps run

See Plans and limits for execution time, app count, and other limits.

This Python (FastAPI) example can be deployed with three files: keelson.yaml, requirements.txt, and app.py.

keelson.yaml
slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none
requirements.txt
fastapi
uvicorn
app.py
import os
from fastapi import FastAPI
import uvicorn
app = FastAPI()
@app.get("/")
def index():
return {"message": "Hello from Keelson"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.environ["PORT"]))

Your AI agent normally writes keelson.yaml, so you do not need to create it yourself.

How apps run

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.

See Database (Managed SQLite) and Files and media.

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.

See Scheduled jobs for setup instructions.

What you want to doHow to implement it
Save a submitted formSave during the request, then return the result
Display an AI answer as it is generatedStream the response during the request
Create a report every morningConfigure a scheduled job
Sync with an external service periodicallyRun one synchronization pass per scheduled job
Accept heavy work and run it after the responseEnqueue it as a background task
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.

The behavior above applies to server-side processing. These restrictions do not apply to browser-side timers or automatic page updates.

HTTP requests, background tasks, and scheduled jobs each have a time limit. Split long-running work into units that finish within the applicable limit.

See Plans and limits, Background tasks, and Scheduled jobs.

Bring an app from another tool

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.

Use a request like this:

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 checkWhy
Language and frameworkTo confirm support and determine how to build and start the app
Data storageTo decide whether to keep the external database or switch to Managed SQLite
Login and permissionsTo ensure existing users can still access their own data
File storageTo check whether stored files would be lost on restart
Timers and background workTo decide whether each piece of work moves to scheduled jobs (runs at set times) or background tasks (continues after a request)

See Supported apps and constraints for languages and frameworks, and How apps run for execution behavior.

If the app uses an external database or storage service

Section titled “If the app uses an external database or storage service”

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 loginWhat to do
Only restricts who can open the appConsider replacing it with Keelson login and app permissions
Restricts which data each user can seeKeep the original authentication, or change authorization to use Keelson user information
Stores the original service’s user IDs in dataDecide 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.

Data written to local files while the app runs does not survive restarts or redeployments. Choose persistent storage based on what you need to keep.

What you need to keepKeelson storage
Customer records, projects, requests, and other data to search and updateManaged SQLite
Internal configuration files or records of processed dataFiles SDK
Images displayed in the app, PDFs, and attachments users openMedia SDK

If you change storage, ask your agent to explain how to move existing data as well as how to change the code.

Before deploying, install the Keelson CLI and Agent Skill and sign in to Keelson. If needed, follow steps 1 and 3 of the quickstart.

After reviewing the changes, ask your agent:

Make the changes we reviewed, create keelson.yaml, and deploy this app to Keelson.

The agent creates a configuration for your app’s structure. The main options are below; you do not need to write the configuration yourself.

App structureHow it is deployed
Static site / SPA (prebuilt HTML, CSS, and JavaScript)Serve built files through assets. This includes apps that call external APIs from the browser
Web app with server-side processing (Next.js, Express, FastAPI, and others)Start the web server with command
Static frontend combined with an API serverSet assets and command, and use assets.api to specify the API path (hybrid structure)

See Configure keelson.yaml to understand the settings and Deploy your app for deployment steps.

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.

  • The app opens at its Keelson URL and the required actions work.
  • Existing data appears, and new or updated data is saved.
  • Images and attachments can be viewed or opened.
  • Users can follow the intended login flow, including any original authentication you kept.
  • Other members can use the app, and cannot see data they should not have access to.

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.

Configure keelson.yaml

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.

This Python web app uses a Keelson database.

slug: my-app # App identifier
runtime: python-slim # Python runtime
command: "python app.py" # Command that starts the web server
db:
mode: libsql # Use Keelson Managed SQLite
FieldWhat to check
slugThe name that identifies the app. It forms part of https://<workspace>--<slug>.keelson.run
runtimeThe runtime for your app’s language: Python, Node.js, or Go
commandThe command that starts the app’s web server. Dependencies are installed during the build
db.modeUse 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 structureMain settings
Web appSet command to the startup command
Static site / SPASet type: web and assets; omit command
Static frontend + API server (hybrid)Set both assets and command
Scheduled jobs onlySet 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.

slug: my-app
runtime: node-slim
command: "node server.js"
db:
mode: libsql
assets:
dir: dist
fallback: index.html
api: /api
What you want to doSettingLearn more
Describe the appdescriptionReference
Pass configuration values to the appenvEnvironment variables and secrets
Use API keys or other secretsDeclare the required names in secrets. Do not write secret values hereEnvironment variables and secrets
Change database tables or columns during deploymentdb.migrateDatabase (Managed SQLite)
Run work at specific times or intervalscronsScheduled jobs
Set the page used to check startuphealth.pathReference
Call the app’s API from another systemDeclare paths and methods in auth.endpoints, and issue an app token separatelyExternal integrations
  • 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
keelson deploy --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.

Keelson SDK

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.

FeatureWhat it doesGuide
Files SDKSave, read, update, delete, and list private files used inside the app, such as configuration JSON or records of processed dataFiles and media: Files
Media SDKStore images, PDFs, and attachments, and serve them by URL to members who can view the app. Use it for product photos or application formsFiles and media: Media
Identity SDKRead the signed-in user’s information, group memberships, and workspace member information. Use it to record authors or vary behavior by groupUse signed-in user information
Tasks SDKEnqueue 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 callsBackground tasks
Email SDKSend email to the app’s users and receive delivery results (delivered, bounce, complaint). Use it for password resets or request confirmationsExternal integrations: Send email

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.

All features are bundled in keelson-sdk.

Terminal window
pip install keelson-sdk

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.

Each feature has its own package. Install only those you need.

FeatureInstall
Filesnpm install @keelsonhq/files
Medianpm install @keelsonhq/media
Identitynpm install @keelsonhq/identity
Tasksnpm install @keelsonhq/tasks
Emailnpm install @keelsonhq/email

Import a package with, for example, import * as files from "@keelsonhq/files".

One module contains packages for each feature.

Terminal window
go get github.com/keelsonhq/go-sdk

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.

Files has language- and OS-related considerations. See Files and media: local development for storage locations and constraints, and Run your app locally for testing user information. The environment variables reference lists settings used by the SDK.

Database (Managed SQLite)

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.

Images and configuration files also have storage options that survive restarts. Choose based on what you are storing and how you use it.

Data and use caseStorage
Create, search, and update customer records, projects, or requestsManaged SQLite
Read and write configuration files or records of processed data inside the appFiles SDK
Display images or let users open PDFs and attachmentsMedia SDK

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.

See Files and media for file storage instructions.

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.

import os
import libsql
conn = libsql.connect(
database=os.environ.get("KEELSON_DB_URL", "file:local.db"), # file: = local development
auth_token=os.environ.get("KEELSON_DB_AUTH_TOKEN", ""),
)
conn.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("INSERT INTO items (name) VALUES (?)", ("Sample",))
conn.commit()
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.KEELSON_DB_URL ?? "file:local.db", // file: = local development
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
await db.execute(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
await db.execute({ sql: "INSERT INTO items (name) VALUES (?)", args: ["Sample"] });

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:

SituationOption
Data volume or performance needs exceed what Managed SQLite can handleExternal PostgreSQL
Very high concurrent write volumePostgreSQL or MySQL
Direct access from BI tools or core business systemsShared PostgreSQL or a data warehouse
Backup retention longer than your Keelson plan providesAn external managed database

All plans include backup and recovery to help recover from accidental deletion or updates.

MethodUse
Daily backupsRestore from an automatically saved daily backup
Manual snapshotsCreate a backup when needed, such as before a major change (up to 5 per app per day, using UTC dates)
Point-in-time recovery (PITR)Restore data to a selected time within the recovery window
DownloadSave a backup or data from a selected time locally
StarterPlusTeam
Daily backups retained137
Point-in-time recovery windowLast 24 hoursLast 7 daysLast 14 days

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.

See Plans and limits for plan-specific limits.

Files and media

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.

Files SDKMedia SDK
Main useSave and update files used in internal app processingDisplay images and let users open PDFs or attachments
ExamplesConfiguration JSON, lists of processed URLs, intermediate summary filesProduct photos, application PDFs, user-facing reports
ReadingServer-side code reads through the SDK. No serving URL is providedServer-side code can read files, and browsers can open them by URL
UpdatesOverwrite the same name (key). Aim for roughly one update per key per secondEach newly saved file gets an ID; existing files cannot be overwritten
Maximum file size10 MiB50 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.

import * as files from "@keelsonhq/files";
await files.write("settings.json", JSON.stringify({ theme: "dark" }));
const raw = await files.read("settings.json"); // Uint8Array | null
const settings = raw ? JSON.parse(new TextDecoder().decode(raw)) : {};
const keys = await files.list("reports/"); // ["reports/2026-08.csv", ...]
await files.delete("reports/2026-07.csv");
import json
from keelson import files
files.write("settings.json", json.dumps({"theme": "dark"}))
settings = json.loads(files.read("settings.json") or "{}") # read() -> bytes | None
keys = files.list("reports/")
files.delete("reports/2026-07.csv")

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.

  1. Call put in server-side code to save the file.
  2. Store the returned file ID in the database alongside the relevant project, product, or other record.
  3. Get the URL with url(id) and use it in an image or link. When the browser opens the URL, Keelson checks authentication and returns the file.

The following server-side function saves an image. Pass the uploaded image bytes as its argument.

import * as media from "@keelsonhq/media";
async function savePhoto(imageBytes) {
const id = await media.put(imageBytes, {
contentType: "image/png",
filename: "photo.png",
});
// Store id in the database and return url to the frontend.
return { id, url: media.url(id) };
}
from keelson import media
def save_photo(image_bytes):
file_id = media.put(
image_bytes,
content_type="image/png",
filename="photo.png",
)
# Store file_id in the database and return url to the frontend.
return {"id": file_id, "url": media.url(file_id)}

On Keelson, URLs have the form /__keelson/media/<file-id>. For example, set an image’s URL as an <img> element’s src, or a PDF’s URL as a link’s href.

<!-- Example URLs for saved files -->
<img src="/__keelson/media/image-file-id" alt="Product photo" />
<a href="/__keelson/media/pdf-file-id">Open application form</a>

Keelson handles the file read. Knowing the URL is not enough to open it; the user needs permission to view the app.

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.

Scheduled jobs

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
FieldMeaning
nameJob name, using lowercase letters, numbers, and hyphens
scheduleCron expression specifying the time or interval
commandCommand that performs one run of the task and exits
timeoutMaximum duration per run in seconds. The job stops when this limit is reached
enabledSet to false to stop scheduled runs. Defaults to true
descriptionOptional. 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.

ScheduleCron expression
Every day at 09:000 9 * * *
Weekdays at 09:000 9 * * 1-5
Every hour, on the hour0 * * * *
Every 15 minutes (Plus / Team)*/15 * * * *
Every 5 minutes (Team)*/5 * * * *
Midnight on the first day of each month0 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.

See Plans and limits for the limits for each plan.

A scheduled job performs one unit of work and exits. It is not a way to keep an app running continuously.

SituationBehavior
Relationship to the web appRuns in a separate environment; memory and local files are not shared
Saving dataFiles 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 runningThe next run of the same job is skipped. It is not queued for later
Run failsNo immediate automatic retry. The next scheduled run proceeds as usual
Time limit is reachedThe 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.

See How apps run.

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

Section titled “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.

report.py
import os
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import libsql
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)
report_date = (datetime.now(ZoneInfo("Asia/Tokyo")).date() - timedelta(days=1)).isoformat()
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM sales WHERE date = ?", (report_date,)
).fetchone()
conn.execute(
"INSERT INTO reports (date, count, total) VALUES (?, ?, ?) "
"ON CONFLICT(date) DO UPDATE SET count = excluded.count, total = excluded.total",
(report_date, row[0], row[1]),
)
conn.commit()
print(f"Report complete: {report_date}, {row[0]} records / {row[1]}")

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";
const db = createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
const res = await fetch(process.env.SYNC_API_URL);
if (!res.ok) throw new Error(`Sync API error: ${res.status}`);
const items = await res.json();
for (const item of items) {
await db.execute({
sql: `INSERT INTO synced_items (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
args: [item.id, JSON.stringify(item)],
});
}
console.log(`Sync complete: ${items.length} records`);

This example assumes the notification logic is implemented in notify.py. Pass the webhook URL through a secret.

slug: daily-notifier
runtime: python-slim
db:
mode: none
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack Incoming Webhook for notifications"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: daily-notify
schedule: "0 9 * * 1-5"
command: "python notify.py"
timeout: 30
SituationWhat to do
timeout exceeds 600 seconds or your plan’s limitReduce it to the limit or below, and split the task if necessary
Schedule is shorter than your plan’s minimum intervalIncrease the interval
Number of jobs exceeds your plan’s limitCombine tasks or remove unused jobs
description is longer than 100 characters (cron_description_too_long)Shorten it to one line of 100 characters or fewer. It is not truncated automatically
type: web is combined with cronsRemove type for a jobs-only app. If adding work to a static site, also review whether it needs a server
Retired workers: configuration remainsRemove it and move the work to crons or request handling as appropriate
  • Work that must start immediately after an event; a scheduled job waits until its next run. Use background tasks instead.
  • Work that cannot be split and exceeds the per-run time limit.
  • Workflow orchestration, such as starting one job only after another finishes.

Background tasks

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 SDK enqueue 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.

WorkWhat to use
Short work that finishes within the request, such as saving a formComplete it while handling the request
Work that runs at set times, such as a morning reportScheduled jobs (crons)
Work that a request starts and that runs afterwardBackground 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
FieldMeaning
nameTask name, using lowercase letters, digits, and hyphens. You pass this name when enqueueing
commandCommand that performs one run of the work and exits
timeoutTime 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_attemptsMaximum number of attempts, including the first. Defaults to 3
descriptionOptional. 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.

from keelson import tasks
task_id = tasks.enqueue("generate-pdf", {"order_id": 1},
idempotency_key="order-1-pdf")
status = tasks.get(task_id)
print(status.status, status.claimed_attempts, status.last_failure_code)
try:
tasks.enqueue("generate-pdf", {"order_id": 2})
except tasks.TasksError as e:
if e.code == "TASK_MONTHLY_QUOTA_EXCEEDED":
... # this month's limit is used up

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):

StatusMeaning
queuedWaiting. Includes waiting to start and waiting to retry
runningRunning
succeededSucceeded (exit code 0)
failedFailed. last_failure_code gives the reason (When a task fails)
cancelledCancelled before it started

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.

A task command is an ordinary program. When Keelson starts the command, it writes one line of JSON to standard input and then closes it:

{"attempt_no":1,"payload":{"order_id":1},"schema_version":1,"task_id":"…","task_name":"generate-pdf"}
KeyContents
attempt_noWhich attempt this is (starting at 1)
payloadInput data passed at enqueue time (null if omitted)
schema_versionVersion of this input format. Currently 1
task_idTask ID. Stays the same across retries
task_nameTask name

The attempt succeeds if the command exits with exit code 0; any other result is a failure.

import json
import sys
doc = json.loads(sys.stdin.readline())
order_id = doc["payload"]["order_id"]
# ... generate and save the PDF ...
# An uncaught exception exits non-zero and counts as a failure

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:

  1. 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';
  2. 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.

  3. 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.

  1. In one transaction, write the order row and an outbox row (ID, task name, input, and an enqueued flag)
  2. After the commit, call enqueue with the outbox row’s ID as the idempotency_key
  3. When the enqueue succeeds, mark the outbox row as enqueued
  4. 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)
│ ────────────────────────────────────────────────────────────▶ │
│ ◀──────────────────────────────────────────────── task_id ──── │
│ ③ Mark the outbox row as enqueued
│ ─────────────────────────────▶ │
④ Rows that failed or stopped before ②③ are resent with the same key by the next request or a cron (no duplicates)
import json
import uuid
from keelson import tasks
def place_order(conn, order_id, items):
outbox_id = str(uuid.uuid4())
conn.execute("INSERT INTO orders (id, items) VALUES (?, ?)", (order_id, items))
conn.execute(
"INSERT INTO task_outbox (id, task_name, payload, enqueued) VALUES (?, ?, ?, 0)",
(outbox_id, "send-receipt", json.dumps({"order_id": order_id})),
)
conn.commit()
flush_outbox(conn)
def flush_outbox(conn):
# Also call this from later requests or a scheduled job
rows = conn.execute(
"SELECT id, task_name, payload FROM task_outbox WHERE enqueued = 0"
).fetchall()
for outbox_id, name, payload in rows:
tasks.enqueue(name, json.loads(payload), idempotency_key=outbox_id)
conn.execute("UPDATE task_outbox SET enqueued = 1 WHERE id = ?", (outbox_id,))
conn.commit()

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_codeMeaning
exit_nonzeroThe command exited with a non-zero exit code
timed_outThe command exceeded its time limit (timeout) and was stopped
no_completionThe deadline passed without a completion report
delivery_failedThe attempt could not start within one hour of becoming ready to run
manifest_unresolvableTwo or more deploys happened while the attempt was waiting, so the declaration it needed could not be resolved
quota_exhaustedThe 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.

ItemLimit
Definitions10 per app
Unprocessed (queued) tasks1,000 per app. Beyond that, enqueueing is rejected with TASK_BACKLOG_LIMIT_EXCEEDED
Run time of one attempt300 seconds by default, 600 seconds maximum. Plan limits: Starter 180 seconds / Plus 300 seconds / Team and above 600 seconds
Attempts3 by default, 5 maximum
Input size65,536 bytes for the whole enqueue request (payload and idempotency_key as JSON). Larger requests fail with TASK_PAYLOAD_TOO_LARGE
Input retentionDeleted 7 days after the task finishes
Monthly limitThe 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:

Terminal window
keelson dev task run generate-pdf --payload '{"order_id": 1}'

Use signed-in user information

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 doWhat to useAdditional setup
Identify the person accessing the app, record authors, or show personal dataUser information headers such as X-Keelson-User-IdNone
Show a screen only to app operators who can deploy or change settingsX-Keelson-User-App-Perms headerNone; assign permissions in the console
Vary behavior by department or job function, or identify approversattributes.groups from the Identity SDKkeelson apps directory enable

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.

HeaderContent
X-Keelson-User-IdUser ID: a stable identifier to store in your database
X-Keelson-User-EmailEmail address, or an empty string if Keelson has no value
X-Keelson-User-NameDisplay 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.

# FastAPI / Flask
user_id = request.headers.get("X-Keelson-User-Id")
email = request.headers.get("X-Keelson-User-Email")
// Express
const userId = req.headers["x-keelson-user-id"];
const email = req.headers["x-keelson-user-email"];

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.

import { getCurrentUser } from "@keelsonhq/identity";
app.get("/", async (req, res) => {
const user = await getCurrentUser({ headers: req.headers }); // { id, email, name }
res.type("text").send(`Hello, ${user.name ?? "Guest"}`);
});

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-PermsMeaning
viewCan view the app
view,manageCan 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.
function canManage(req) {
const perms = (req.headers["x-keelson-user-app-perms"] ?? "").split(",");
return perms.includes("manage");
}
app.get("/admin", (req, res) => {
if (!canManage(req)) return res.status(403).end();
res.send("App operator screen");
});
# FastAPI / Flask: also read per request.
def can_manage(request) -> bool:
perms = request.headers.get("X-Keelson-User-App-Perms", "").split(",")
return "manage" in perms

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
keelson apps directory enable
keelson deploy

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.

import { getCurrentIdentity } from "@keelsonhq/identity";
// Read inside a request handler.
const me = await getCurrentIdentity({ headers: req.headers });
const groups = me.attributes?.groups ?? []; // Example: ["everyone", "developers", "accounting"]
const isAccountingMember = groups.includes("accounting");
// 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.
const page = await listMembers({ group_key: "developers", limit: 100 });
const options = page.items.map((m) => ({
id: m.id,
label: m.name || m.email,
image_url: m.image_url, // string or null
}));
from keelson import identity
page = identity.list_members(group_key="developers", limit=100)
options = [
{"id": m.id, "label": m.name or m.email, "image_url": m.image_url}
for m in page.items
]

In the browser, show initials when there is no image or the image fails to load:

function avatar(option) {
const initials = document.createElement("span");
initials.className = "avatar-initials";
initials.textContent = option.label.slice(0, 1).toUpperCase();
if (!option.image_url) return initials;
const img = document.createElement("img");
img.className = "avatar";
img.alt = "";
const src = new URL(option.image_url);
src.searchParams.set("width", "64");
src.searchParams.set("height", "64");
img.src = src.toString();
img.addEventListener("error", () => img.replaceWith(initials), { once: true });
return img;
}

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.

Run your app locally

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.

Through keelson dev serveKEELSON_LOCAL_MODE=1 (no CLI)
UserSwitch to anyone in the user listOne fixed user
How user information arrivesThe same headers as in productionThe SDK returns test data (no headers)
Member list and groups (listMembers and so on)Returned from the user list (no need to enable the Directory API)Returned from the user list
RequiresThe latest Keelson CLIThe 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.

After --, write the command that starts your app’s server.

Terminal window
# Node.js
keelson dev serve -- node server.js
# Python
keelson dev serve -- uvicorn main:app --host 127.0.0.1 --port {port}
# Go
keelson dev serve -- go run .
  • 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
MethodHow to use it
Switch pageOpen http://localhost:5173/__keelson/dev in your browser and choose a user. Other open tabs keep the previous user until you reload them
X-Keelson-Dev-As headerAdd X-Keelson-Dev-As: <user ID> to a request to act as that user for that request only. Use it when testing with AI agents or curl
--asRun keelson dev serve --as <user ID> -- ... to choose the user at startup
Terminal window
# Call the API as a user with view permission only
curl -H "X-Keelson-Dev-As: local-user-003" http://localhost:5173/api/items

An ID that is not in the user list returns an error.

Provide the list of users as a JSON file. It is looked up in this order:

  • keelson dev serve: --users <file> → ./.keelson/dev-users.json
  • KEELSON_LOCAL_MODE=1: the KEELSON_LOCAL_USERS_FILE environment variable → ./.keelson/dev-users.json
{
"users": [
{ "id": "sample-alice", "email": "[email protected]", "name": "Alice Smith", "perms": ["view", "manage"] },
{ "id": "sample-bob", "email": "[email protected]", "name": "Bob Jones", "perms": ["view"] }
]
}
FieldContent
idUser ID. Must be unique within the list. Any readable string works; it does not need to match production IDs
email / nameEmail 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_urlOptional. 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
  • Restart after changing the user list

Run without the CLI (KEELSON_LOCAL_MODE=1)

Section titled “Run without the CLI (KEELSON_LOCAL_MODE=1)”

Start your app’s server directly with the KEELSON_LOCAL_MODE=1 environment variable.

Terminal window
KEELSON_LOCAL_MODE=1 node server.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)

Section titled “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.

import { getRequestUser } from "@keelsonhq/identity";
app.get("/admin", async (req, res) => {
const user = await getRequestUser({ headers: req.headers }); // { id, email, name, perms }
if (!user.perms.includes("manage")) return res.status(403).end();
res.send(`Hello, ${user.name ?? user.id}`);
});
from keelson import identity
user = identity.get_request_user(headers=request.headers)
if "manage" not in user.perms:
... # Return 403
client, err := identity.New("") // Once at startup
// Inside the handler
user, err := client.GetRequestUser(identity.WithHeaders(r.Header))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
canManage := slices.Contains(user.Perms, "manage")

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.

These local development features do not work in an app deployed on Keelson.

  • If KEELSON_LOCAL_MODE is set in a deployed app, the SDK raises an error instead of returning test data. Remove KEELSON_LOCAL_MODE
  • keelson dev serve also refuses to start in a deployed environment

Use your app from AI clients (MCP)

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.

  1. The app author declares tools in the mcp: section of keelson.yaml and deploys
  2. A user registers the connection URL (the app URL + /mcp) in their AI client
  3. The first time, the Keelson login and consent screens appear. When the user approves, the AI client is connected as that user
  4. When the AI client calls a tool, Keelson checks membership, app permissions, and IP restrictions, and validates the arguments against the declaration
  5. 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.

Add an mcp: section to keelson.yaml and deploy. The example below declares a read tool, search_items, and a write tool, create_arrival.

slug: stock-desk
runtime: python-slim
command: "python app.py"
db:
mode: libsql
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]
KeyMeaning
enabledtrue publishes the tools. false keeps the declaration but stops calls
instructionsA description of the tools as a whole, passed to the AI client (optional)
tools[].nameTool name. Starts with a lowercase letter and uses only lowercase letters, digits, and _. Your app’s endpoint is POST /api/mcp/<name>
tools[].descriptionThe tool description shown to the AI client. The AI reads it to decide when to call the tool
tools[].accessread or write (changes data). See Read and write tools, permissions
tools[].permissionview (default) or manage. A manage tool can be used only by people with manage permission on the app
tools[].inputThe shape of the arguments (JSON Schema 2020-12). The top level is type: object

For the types, limits, and validation of each key, see the keelson.yaml reference.

Put many tools in a separate file (mcp.file)

Section titled “Put many tools in a separate file (mcp.file)”

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.

HeaderContent
X-Keelson-User-IdUser ID of the connected user. Use this to identify the user
X-Keelson-User-EmailThe same user’s email address
X-Keelson-User-NameThe same user’s display name
X-Keelson-User-App-PermsThe user’s permissions on the app (view or view,manage)
X-Keelson-Auth-Sourcemcp (indicates a call through MCP)
X-Keelson-Mcp-ToolName of the called tool
X-Keelson-Mcp-ClientIdentifier (URL) of the AI client
X-Request-IdRequest 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 responseResult returned to the AI client
2xx with a JSON bodyThe JSON body as the tool result
2xx with an empty body, a non-JSON body, or a body over 1 MiBError (invalid tool response)
4xxError. The first 4 KiB of the body is shown as is to the user and the AI client
3xx or 5xxError (tool execution failed). The body is not shown
No response within 60 secondsError (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 retry failed calls
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/api/mcp/search_items")
async def search_items(request: Request):
user_id = request.headers["X-Keelson-User-Id"]
args = await request.json() # e.g. {"query": "bolt"}
items = find_items(owner=user_id, query=args["query"]) # your app's search
return {"items": items}
@app.post("/api/mcp/create_arrival")
async def create_arrival(request: Request):
perms = request.headers.get("X-Keelson-User-App-Perms", "").split(",")
if "manage" not in perms:
return JSONResponse({"error": "You do not have permission to record arrivals"}, status_code=403)
args = await request.json()
if not item_exists(args["item_id"]):
# The body of a 4xx response is visible to the user
return JSONResponse({"error": f"Item {args['item_id']} was not found"}, status_code=404)
arrival_id = record_arrival(args["item_id"], args["quantity"])
return {"arrival_id": arrival_id}
app.use(express.json());
app.post("/api/mcp/search_items", (req, res) => {
const userId = req.headers["x-keelson-user-id"];
const items = findItems({ owner: userId, query: req.body.query }); // your app's search
res.json({ items });
});
app.post("/api/mcp/create_arrival", (req, res) => {
const perms = (req.headers["x-keelson-user-app-perms"] ?? "").split(",");
if (!perms.includes("manage")) {
return res.status(403).json({ error: "You do not have permission to record arrivals" });
}
const { item_id, quantity } = req.body;
if (!itemExists(item_id)) {
// The body of a 4xx response is visible to the user
return res.status(404).json({ error: `Item ${item_id} was not found` });
}
res.json({ arrival_id: recordArrival(item_id, quantity) });
});

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.

  1. Register the connection URL in the AI client
  2. The first time, the Keelson login screen opens. Log in with an account that can use the app
  3. 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
ActionWhoEffect
Stop MCP for the workspace (Settings → Security → MCP (AI clients))Owner / AdminStops calls to every app in the workspace. Connections remain and work again once MCP is allowed
Deploy with mcp.enabled: falsePeople who can deploy the appStops 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 tabOwner / Admin, people with manage permission on the appDisconnects that connection. The AI client immediately loses access, including its renewal token
Revoke under “Your connected AI clients”The user who connectedDisconnects 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

For the list of reserved paths, see Reserved URL paths in the Keelson Deploy Spec.

ItemLimit
Number of tools40 per app
instructions4096 characters
Tool description1024 characters
Tool input64 KB as JSON, 32 levels of nesting
One call60 seconds
Response body (2xx)1 MiB
Calls60 per minute per connection
Concurrent calls10 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.

SymptomWhat to check
A tool does not appear in the listWhether 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 restrictionsIP 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 shownWhether the handler returns 2xx with a JSON body, and the body is not empty, plain text, or over 1 MiB
Cannot connect after logging inWhether 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 keyThe Keelson CLI is old. Run keelson upgrade to update to the latest version, then deploy again

External integrations

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.

What you want to doSetup
Call an external APISet connection details and API keys
Send email to the app’s usersSend with the Email SDK
Receive notifications from an external serviceCreate a webhook app token and configure a receiving endpoint
Call the app’s API from another internal systemCreate an app token and declare allowed paths
Use an external database or storage serviceSet connection details

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.

Terminal window
keelson apps tokens create --app my-app --name webhook-client --scope webhook

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 configureSetup
Custom HTTP headersUse /api/webhooks/incoming as the receiving path and add X-Webhook-Secret: <issued-token>
Receiving URL onlyUse /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.

Issue an app token to call the app’s API from another internal system or a script.

Terminal window
keelson apps tokens create --app my-app --name batch-client --scope api --allowed-ip 203.0.113.0/24

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.

Terminal window
curl -H "Authorization: Bearer keelson_xxx" https://acme--my-app.keelson.run/api/external/status
auth:
endpoints:
- path: /api/external/status
methods: [GET]
- /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.

import * as email from "@keelsonhq/email";
const result = await email.send({
subject: "Request received",
text: "We received request 1234.",
});
// 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.

  1. 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.
  2. Add the TXT record shown under Ownership check to your DNS, then select Check status. Only one workspace can verify a given domain.
  3. 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.
  4. Under Allowed sender addresses, add each address your apps send from.
  5. In your app, pass the address as from_address (Node and Python) or From (Go).
await email.send({
from_address: "[email protected]",
from_name: "Example Support",
subject: "Request received",
text: "We received request 1234.",
});
  • 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.

HTTPError codeMeaning
400RECIPIENT_SUPPRESSEDA recipient is on the workspace suppression list (see below)
403EMAIL_SENDING_SUSPENDEDEmail 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
403SENDER_DOMAIN_NOT_AVAILABLEThe From address is on your own domain, but the workspace is not on the Team plan
403DOMAIN_NOT_VERIFIEDThe From domain is not verified for the sending app’s workspace, or the domain is limited to another app
403SENDER_ADDRESS_NOT_ALLOWEDThe From address is not an allowed sender address for its domain
422RECIPIENT_LIMIT_EXCEEDEDMore than 50 recipients in total
429RATE_LIMIT_EXCEEDEDThe 60-second send limit was reached. Retry later
429MONTHLY_QUOTA_EXCEEDEDThis month’s recipient limit was reached. It resets next month
429GLOBAL_RATE_LIMIT_EXCEEDED / GLOBAL_DAILY_QUOTA_EXCEEDEDKeelson’s overall sending volume temporarily reached its limit. Retry later
502SEND_OUTCOME_UNKNOWNKeelson could not confirm whether the message was sent. It may have been delivered, so do not retry automatically

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
const event = 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.

FieldContent
event_idNotification ID. The same notification can arrive more than once, so use it to detect duplicates
event_typedelivered, bounce, or complaint
email_addressThe recipient address
send_idThe send_id returned when the message was sent. Use it to match the notification with your send record
bounce_typeFor bounces, hard (permanent) or soft (temporary)
detailAdditional information
providerName of the sending infrastructure used for delivery
timestampWhen 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.

For files that exceed the Files / Media SDK limits, write directly to S3-compatible storage from your app. Pass credentials through secrets.

Receive email

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.

Add email.inbound to keelson.yaml and deploy. Receiving needs Managed SQLite (db.mode: libsql).

slug: order-inbox
runtime: python-slim
command: "uvicorn main:app --host 0.0.0.0 --port $PORT"
db:
mode: libsql # required
email:
inbound:
enabled: true
on_receive: process-mail # optional: a task declared under tasks
tasks:
- name: process-mail
command: "python process_mail.py"
FieldMeaning
email.inbound.enabledtrue turns receiving on. The deploy issues the receiving address and creates the table keelson_inbound_emails
email.inbound.on_receiveOptional. The name of a task under tasks. Keelson starts it after each email is saved

The deploy is rejected in these cases:

ErrorCause
email_inbound_requires_libsqlenabled: true without db.mode: libsql
email_on_receive_requires_enabledon_receive is set but enabled is not true
email_on_receive_unknown_taskon_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.

ColumnContents
email_idPrimary key. Fixed when the email is received
schema_versionVersion of the column layout when the row was written. Currently 1
received_atTime received, ISO 8601 UTC
envelope_toThe app’s receiving address the email was delivered to
envelope_fromThe sender address used for delivery (the envelope sender)
from_addressThe sender’s address: X-Original-From if present, otherwise From. Not authenticated (below)
from_nameThe sender’s display name, from the same header
header_fromThe raw From header, after any rewriting by a forwarding group
reply_toReply-To header
to_addressesJSON array of the To addresses (for forwarded mail, your shared inbox)
cc_addressesJSON array of the Cc addresses
subjectSubject
message_idMessage-ID header
in_reply_toIn-Reply-To header
references_headerReferences header
list_idList-Id header
text_bodyPlain-text body, decoded to UTF-8
html_bodyHTML body, decoded to UTF-8
body_truncated1 if either body was longer than 1 MiB and was cut
attachmentsJSON array of attachments ([] if none)
spam_scoreSimple spam score. A marker only (Spam)
auth_spf / auth_dkim / auth_dmarcSPF / DKIM / DMARC results recorded by the receiving server

Headers that are missing are NULL. Encoded subjects and file names (including Japanese) are decoded.

to_addresses and cc_addresses are arrays of addresses (name is null when absent):

[{"address": "[email protected]", "name": "Support"}]

Each item in attachments:

[{"attachment_id": "att_01", "filename": "order.pdf", "content_type": "application/pdf",
"content_id": null, "size_bytes": 182344, "files_key": "inbound/<email_id>/att_01",
"sha256": "<hex>"}]

content_id is set for inline images referenced from the HTML body.

-- Latest 20 emails
SELECT email_id, received_at, from_address, subject
FROM keelson_inbound_emails
ORDER BY received_at DESC
LIMIT 20;
-- Attachments of one email
SELECT json_extract(a.value, '$.filename') AS filename,
json_extract(a.value, '$.files_key') AS files_key
FROM keelson_inbound_emails e, json_each(e.attachments) a
WHERE e.email_id = ?;

Attachments are saved in Files under the key inbound/<email_id>/<attachment_id>, which is the files_key in attachments. Read them with the Files SDK.

import json
from keelson import files
(attachments,) = conn.execute(
"SELECT attachments FROM keelson_inbound_emails WHERE email_id = ?", (email_id,)
).fetchone()
for att in json.loads(attachments):
data = files.read(att["files_key"]) # bytes | None
import * as files from "@keelsonhq/files";
const { rows } = await db.execute({
sql: "SELECT attachments FROM keelson_inbound_emails WHERE email_id = ?",
args: [emailId],
});
for (const att of JSON.parse(rows[0].attachments)) {
const data = await files.read(att.files_key); // Uint8Array | null
}

An attachment can be larger than the Files write limit (10 MiB). Reading it works. Attachments count toward the workspace storage; the rows do not.

  • 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.

When on_receive names a task, Keelson starts that task after each email is saved. The task’s payload is the email ID only:

{"attempt_no":1,"payload":{"email_id":"…"},"schema_version":1,"task_id":"…","task_name":"process-mail"}

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:

import json
import os
import sys
import libsql
email_id = json.loads(sys.stdin.readline())["payload"]["email_id"]
conn = libsql.connect(
database=os.environ.get("KEELSON_DB_URL", "file:local.db"),
auth_token=os.environ.get("KEELSON_DB_AUTH_TOKEN", ""),
)
conn.execute(
"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.

Keelson does not receive mail for your own domain. Keep receiving mail where you receive it now and forward it to the app’s address.

  1. In Gmail, open Settings → See all settings → Forwarding and POP/IMAP and add the app’s receiving address as a forwarding address.
  2. 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.
  3. 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.

  1. 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.
  2. 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.

ItemLimit
Size of one email25 MiB, including attachments. Larger emails are refused
Bodytext_body and html_body are each cut at 1 MiB (body_truncated = 1)
AttachmentsNo limit per attachment within the 25 MiB
Emails received per monthPer workspace, all apps combined. See below

Monthly receiving limit (per workspace, calendar month in UTC):

StarterPlusTeamBusiness
Emails received per month5003,00010,00030,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.

  1. 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).
  2. Check the address. Mail to a rotated address, or to an app with receiving turned off, is refused.
  3. 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.
  4. Check the forwarding. Gmail forwarding starts only after you enter the confirmation code. Microsoft 365 blocks external forwarding unless an administrator allows it.
  5. 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
keelson dev email ./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:
Terminal window
keelson dev task run process-mail --payload '{"email_id":"…"}'

To get an .eml file, download the original message from your mail client (in Gmail, Show original → Download original).

Framework notes

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.
import os
ON_KEELSON = os.environ.get("KEELSON_MODE") == "keelson"
DEBUG = os.environ.get("DEBUG", "false").lower() == "true" # Defaults to false
command: "uvicorn main:app --host 0.0.0.0 --port $PORT"
env:
PYTHONUNBUFFERED: "1"

Do not add --reload: file watching consumes memory and can start the app twice. Keep the default of one worker.

app.run() starts a development server. Add gunicorn to requirements.txt and use it to start the app.

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 app:app"
env:
PYTHONUNBUFFERED: "1"

Replace app:app with your module name and app object name. --graceful-timeout 9 keeps graceful shutdown within the 10-second SIGTERM window.

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.

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 config.wsgi:application"
db:
mode: none
secrets:
items:
- name: DATABASE_URL

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.

Streamlit currently does not work because it requires WebSocket. Gradio 4 and later are being evaluated.

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.

command: "npm start" # package.json: "start": "node server.js"
  • Do not return stack traces from error handlers.
  • 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.

Build the app and start it with next start. next dev is not a production server.

command: "npm run start" # package.json: "start": "next start -p $PORT"
  • npm run build runs automatically during the build. Check that it produces .next/.
  • Change "start": "next dev" if present.
  • Read KEELSON_DB_URL on the server. Putting it in a NEXT_PUBLIC_* variable sends it to the browser.

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.

Deploy


Deploy an app

Deploying sends your app to Keelson so people can use it. You deploy when you first set up an app and whenever you want to apply changes.

Keelson is designed to work with AI agents. After the initial setup, you can ask your agent to check the configuration, deploy, and verify the result.

Creating an app requires the Owner, Admin, or Developer role. Updating an existing app requires its manage permission (manage).

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:

  1. Check that the app’s storage and startup settings work with Keelson.
  2. Create or update keelson.yaml and explain where to register API keys and other required values.
  3. Deploy and monitor the deployment through completion.
  4. If it fails, inspect the logs and explain the cause and how to resolve it.
  5. 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.

ChangeWhen it takes effect
Code and keelson.yaml settingsOn deployment
SecretsOn the next deployment after saving. Apply changes also redeploys with the new values without uploading or rebuilding the code
App permissions, IP restrictions, and app URLWithout deployment, usually within two minutes. Changes made during an outage take effect after recovery

If the app uses its own URL internally, also see Read the app URL in your code.

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.

You can also deploy from a terminal without an agent.

Terminal window
# First deployment: create and deploy the app
keelson deploy --new
# Register secrets with the first deployment
keelson deploy --new --secrets-from-env-file .env.keelson
# Subsequent deployments
keelson deploy
# Check configuration and archive contents without uploading
keelson deploy --check --json
  • The file passed to --secrets-from-env-file is excluded from the upload archive.
  • If you belong to multiple workspaces, add --workspace <slug>.
  • To specify the app to update, add --app <slug>. The uploaded source still comes from the folder where you run the command.
  • In scripts, use keelson deploy --ndjson --yes to wait for completion and receive one JSON object per line.

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:

  1. Validation — checks keelson.yaml and the files being uploaded.
  2. Build — installs dependencies and packages the files needed to run the app into a container image. The build limit is 600 seconds.
  3. Startup — starts the app with the new code and configuration. This running version is called a revision.
  4. Health checks — checks that the app responds at health.path (default /) and the paths in verify.
  5. Migration — runs db.migrate, if configured, to update the database structure or data.
  6. 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.

Verify a deployment

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.

  • Can you open the app with your account?
  • 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.

Check a single response with keelson app curl

Section titled “Check a single response with keelson app curl”

Use these commands to retrieve a page or API response:

Terminal window
keelson app curl / # GET /
keelson app curl -i /api/items # Include response headers

Creating, updating, or deleting data and uploading files change the actual app. These examples perform writes; adjust the paths and data for your app.

Terminal window
keelson app curl /api/items --method POST \
--data '{"name":"example"}' \
--header 'Content-Type: application/json'
keelson app curl /upload --form title=example --form [email protected]
  • 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

Section titled “Use another tool or make multiple requests with keelson preview”

Issue a token directly if you want to use another HTTP client, make several requests, or choose the token lifetime.

Terminal window
keelson preview # GET / HEAD only; default 30 minutes
keelson preview --ttl 5m
keelson preview --allow-writes # Also POST / PUT / PATCH / DELETE; default 5 minutes, maximum 10
keelson preview --json # Token, URL, and expiry as JSON

Use the token and app_url returned by --json in your HTTP client. Send the token as a Bearer token.

Terminal window
curl --header 'Authorization: Bearer <token>' '<app_url>/api/items'

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.

Environment variables and secrets

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.

TypeExamplesWhere to set it
Non-secret configurationNODE_ENV, LOG_LEVEL, public API URLsenv in keelson.yaml
Secret valuesAPI keys, tokens, database connection strings, webhook signing secretsSecrets, 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:

Terminal window
# Register with the first deployment
keelson deploy --new --secrets-from-env-file .env.keelson
# Register while updating an existing app
keelson deploy --secrets-from-env-file .env.keelson
# Register and apply without uploading or rebuilding the code
keelson secrets set --from-env-file .env.keelson --apply

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 following sections cover configuration and code details. Ask your agent to put non-secret values under env in keelson.yaml.

env:
NODE_ENV: "production"
LOG_LEVEL: "info"
  • Quote all values, including numbers and booleans. Unquoted values cause deployment to be rejected.
  • Names starting with KEELSON_ are rejected; Keelson supplies them. PORT is ignored if set, and Keelson’s value is used.
  • Changes take effect on the next deployment.

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.

ScopeWhere to set itUse
WorkspaceSecrets in the consoleValues shared across apps, such as a common API key
AppThe app’s Secrets tabApp-specific values or overrides of workspace values

For the same name, precedence is app secret > workspace secret > env. The app’s Secrets tab shows which scope each value comes from.

Server-side code reads both env values and secrets as environment variables.

import os
api_key = os.environ["OPENAI_API_KEY"]
const apiKey = process.env.OPENAI_API_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.

VariablePurpose
PORTThe listening port for a web app.
TZThe workspace time zone.
KEELSON_MODEIndicates platform mode.
KEELSON_APP_IDThe internal app ID.
KEELSON_WORKSPACE_IDThe internal workspace ID.
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID, with the same value.
KEELSON_DEPLOY_IDThe internal ID of the current deployment.
KEELSON_APP_URLThe standard app URL.
KEELSON_DIRECTORY_BASE_URLThe 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.

App URLs and domains

Web apps and static sites receive a dedicated URL that you can share after deployment. You can also use a domain your company manages.

The standard URL has this format. A slug is an identifier used in URLs and other settings.

https://<workspace-slug>--<app-slug>.keelson.run

For workspace slug acme and slug: dashboard in keelson.yaml, the URL is https://acme--dashboard.keelson.run. Keelson manages the HTTPS certificate.

Find the URL on the app page in the console, in your agent’s deployment report, or with keelson status.

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:

  1. 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.
  2. 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.
  3. 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.

App status and logs

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.

StatusMeaning
ActiveRecent requests are using one concurrent app slot
SleepingDeployed, but not using a slot because there have been no recent requests. A stopped app starts automatically on access
PublishedServing a static site or SPA. Keelson returns the page files directly
SuspendedManually stopped. Requests do not start the app or resume serving. Data, URL, and configuration are retained
Starting / DeployingStarting or stopping, or applying new code or configuration
Not deployedCreated, but not yet deployed
ErrorThe latest deployment or execution failed. The app page shows the cause
DeletingDeletion 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.

Choose the log that matches what you are investigating.

What you want to knowWhere to lookWhat it records
Why a deployment failedDetails for a deployment in DeploysThe failure summary and messages for you, such as warnings about your keelson.yaml. Details appears only on deployments that failed or have warnings
Why an action failed in the appApp logs under LogsActivity and errors written by the app to standard output and standard error
Why a user was denied accessAccess logs under LogsWho accessed which path, when, and whether access was allowed or denied
Whether a scheduled job succeededRun history under Scheduled JobsStart time, result, duration, and logs for each run

Give your agent the app name, time, and what happened:

In this app, [action] produced an error at [date and time]. Check the status and logs, then explain the cause and how to resolve it.

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.

Update and restore

You can deploy app changes or return to earlier code or data after a problem. Choose the action that matches what you need.

GoalAction
Apply changesDeploy again
Return to the code before an updateRoll back
Recover accidentally changed or deleted dataUse a database restore point
Temporarily stop accessSuspend
Retire the appDelete

These actions require the app’s manage permission (manage). Database restores and app deletion also require approval in a browser.

Restoring code does not restore the database or files to their earlier state. See What each operation restores for the full comparison.

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.

  1. Check your changes in your local development environment.
  2. 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.
  3. 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
keelson rollback # Return to the previous successful deployment
keelson rollback <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.

  1. 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.
  2. You can Download the selected restore point to inspect it. This downloads that historical state, not the current database.
  3. Select Restore and approve the operation in your browser.
  4. 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.

Suspending and deleting have different effects.

ActionWhat happensCan you undo it?
Suspend, under Settings → Suspend for web apps and static sites / SPAsRequests, webhooks, and cron do not start the app. The URL shows a stopped page. Data, URL, and configuration remainYes, use Resume
Delete, under Settings → Danger ZoneRemoves the app, URL, deployment history, and secrets. Requires browser approvalNo

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.

See Billing and subscriptions for what happens when a subscription ends.

“Rollback” below means returning the code to an earlier deployment. “Database restore” means returning Managed SQLite to a restore point.

ItemRestored by rollback?Restored by database restore?Notes
Code (image)YesNoStarts the target image without rebuilding. Excludes static sites / SPAs
keelson.yaml settings (env, crons, assets, health)YesNoUses the target deployment’s configuration
Secret valuesNoNoAlways uses current values. Change them manually in the console
Managed SQLite dataNoYesDaily backups, manual snapshots, and PITR
Schema changes made by db.migrateNoYesReturns the database structure to the restore point
Files SDK filesNoNoNot backed up; retention is the app’s responsibility
Media SDK filesNoNoCannot be overwritten; save a new file to replace one
External databases, such as PostgreSQLNoNoUse the external service’s backups
External actions, such as Slack posts, emails, or paymentsNoNoRollback does not undo them. Avoid duplicates when retrying
App permissions, IP restrictions, and public URLNoNoManaged independently of deployment. Revert manually

Troubleshooting

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.

SymptomWhat to check
Cannot deployIdentify the failing stage
Cannot open the appPermissions, available slots, response time limits
Changes do not appearPage updates, applying secrets
Data is not retainedStorage location
Scheduled or background work does not runScheduled jobs, background processing

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
keelson status # App and latest deployment status
keelson diagnose <deploy_id> # Diagnose a failed deployment
keelson logs deploy <deploy_id> # Progress and stored failure details
keelson logs app <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.

StageWhere to lookCommon causes
Before upload: CLI preflight checksCommand outputMissing go.mod for Go; only pnpm / yarn lockfiles for Node; pip install in command; missing db.mode; unquoted env values
Validationcode and messageLeftover workers / databases, reserved names, plan limits
BuildDeployment logsDependency resolution failure, mismatched package.json and package-lock.json, private registries, Go packages requiring cgo, build script errors
StartupSaved app startup logs from keelson logs deploy <deploy_id>Not listening on 0.0.0.0, hardcoded PORT, exceptions caused by missing secrets
Health checksSaved startup logs, then app logsApp exits during startup, / returns 5xx, startup exceeds the health-check deadline
MigrationDeployment logsSQL 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
Running but behaving unexpectedlyApp logs and access logsSee the common problems below

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
keelson status <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.

Terminal window
keelson rollback --app <slug>

Static sites and SPAs do not support rollback; redeploy the earlier files. See Restore an earlier version of the code.

If you need to stop access temporarily, for example because no rollback target is available, suspend the app after deployment finishes.

Terminal window
keelson app stop --app <slug>

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.

  • Is the schedule more frequent than the plan’s minimum interval? Deployment rejects it.
  • Is the previous run still in progress? Overlapping runs are skipped.
  • Has the monthly execution limit been reached? Further runs are skipped for that month.
  • Is the app suspended?
  • Check the run history in Scheduled Jobs for the reason a run was skipped.

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.

  1. Are they a workspace member, and have they accepted their invitation?
  2. Is their membership active, rather than deactivated?
  3. Do they belong to a group with view permission for the app?
  4. Are they signed in with the registered account?
  5. Are IP restrictions denying access? The 403 page shows the source IP.

See Troubleshooting access.

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)
  • Do the token scope and allowed IPs match?

See External integrations.

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.

For a platform error such as deploy.platform.error, or if these steps do not resolve the issue, use the contact form.

Include the following to help us investigate:

InformationWhere to find it
Workspace slugslug in keelson workspaces list --json. URLs may still use a name from before a rename
App slugslug in keelson.yaml or keelson apps list --workspace <workspace-slug> --json
deploy_idOutput of keelson status --json or the console’s Deploys tab
Error code, message, and hintCLI output. Add --json to retrieve them together
Date, time, and time zoneUsed to find the relevant logs
For access problems, the user’s email and the IP on the 403 pageSee A user cannot access the app

Do not send app source code or secret values. If needed, support will ask for specific information.

Use the same contact form for questions about pricing, subscriptions, or invoices. See Billing and subscriptions.

Access Control


Authentication and login

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.

App users can sign in with any of the following:

  • Google account
  • Microsoft account
  • Email address (a one-time login code sent by email)

Users must sign in with the email address registered in the workspace. If they use several accounts, they need to select the registered one.

Creating a workspace (signing up for the console) requires a Google or Microsoft account.

  1. Open the app URL.
  2. If you are not signed in, Keelson displays its login page.
  3. Sign in with Google, Microsoft, or the code sent to your email.
  4. 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.

If an app does not open, see Troubleshooting access.

Use the signed-in user’s information in your app

Section titled “Use the signed-in user’s information in your 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:

HeaderContents
X-Keelson-User-IdUser ID
X-Keelson-User-EmailEmail address
X-Keelson-User-NameName
X-Keelson-User-App-PermsPermissions 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.

Members and roles

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.

RoleMain responsibilities and capabilities
OwnerResponsible for the workspace. Manages members, including other Owners, settings, and billing
AdminManages members other than Owners, and workspace settings
DeveloperBuilds, deploys, and updates apps. Cannot manage members or change workspace settings
App UserUses 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.

MethodHow membership starts
InvitationAn Owner or Admin invites an email address. The recipient opens the link and accepts the invitation
Join URLSomeone 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.

RoleAutomatic group membership
Ownerowners developers everyone
Adminadmins developers everyone
Developerdevelopers everyone
App Usereveryone

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.

  1. Are you signed in with the registered account? If you use several Google or Microsoft accounts, check the email address.
  2. 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.
  3. Has the member been disabled? Check Deactivated on the Members page.
  4. Does the member have view or manage permission? Open the app’s Permissions tab and check the assignments for their groups.
  5. 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.

Share an app with your team

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.

  1. Select Edit under View Permission.
  2. Clear Everyone (everyone) and select Sales (sales).
  3. 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

Section titled “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.

CheckExpected result
A Sales group member opens the URLThe app appears after login
An app manager outside the Sales group opens the URLThe app appears after login
An App User outside Sales, with no view or manage permission through another group, opens the URLAn access denied page appears after login
Someone who is not signed in opens the URLKeelson’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.

ProblemWhat to check
A sales team member cannot open the appConfirm they have joined the workspace, belong to sales, and are signed in with their registered email address
Someone outside Sales can open the appCheck whether everyone or another view group is still assigned, or whether the person belongs to a manage group
Saved settings do not change accessChanges may take time. Wait briefly, reload, and check the saved Permissions settings and the account used for testing

For other checks, including disabled members and IP restrictions, see Troubleshooting 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.

Terminal window
keelson groups create sales --name "Sales"
keelson groups members add sales [email protected] [email protected]
keelson access set --app sales-tool --view sales --manage developers

Pass the view and manage groups you want to keep to access set. This example sets view access to sales and manage access to developers.

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
PermissionWho configures it and whereExamples
Workspace management (roles)Assigned by an Owner or Admin. Only Owners can assign or change the Owner roleInviting members or handling billing
App view and manage permissions (view / manage)Assigned to groups by someone with manage permission for that appOpening the app, deploying it, or changing settings
Business permissions inside the appImplemented by the appViewing 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.

Workspace


Invite members

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 invitingRoleDevelopers seat
Someone who uses appsApp UserNot used
Someone who builds and deploys appsDeveloperUsed
Someone who also manages membersAdminUsed
Someone who shares responsibility for the workspaceOwnerUsed

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.

  1. Open Members in the console and select Invite Member.
  2. Enter email addresses (up to 50 at a time).
  3. Choose a role.
  4. 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.

  1. Open the link in the email.
  2. 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.
ProblemWhat to check
You cannot create an invitationAre you an Owner or Admin? Has the person already joined? For Developer, Admin, or Owner invitations, is a seat available?
The email does not arriveCheck 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 acceptAre they signed in with the invited address? Has the invitation expired or been revoked?
The person joined but cannot open an appCheck app view permission, the signed-in account, and IP restrictions

To let people with your company’s email domain join without individual invitations, configure Domains and Join Policies (Team and above).

Groups and app permissions

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.

For a walkthrough, see Share an App with Your Team.

PermissionWhat it allows
View (view)Open and use the running app
Manage (manage)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.

New apps automatically receive these assignments:

GroupPermission
everyone (all members)View
developers (Owners, Admins, and Developers)Manage

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.

Manage groups in Members → Groups.

System groups are assigned automatically by role and cannot be edited.

KeyMembers
ownersOwners
adminsAdmins
developersOwners, Admins, and Developers
everyoneAll 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.

Open the app’s Permissions tab and select its view and manage groups.

  • At least one manage group is required. You cannot leave the assignment empty or assign only groups with no members.
  • Removing your own manage permission requires confirmation, even if another manage group has active members.
  • Saved changes normally take effect within two minutes, including for users who are already signed in.
GoalConfiguration
An internal tool everyone can useKeep the defaults
An app for Accounting and the developers who manage itView: accounting; manage: developers
Everyone can use it, but only Owners and Admins can change settingsView: everyone; manage: owners and admins

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.

Terminal window
keelson groups list
keelson groups create accounting --name "Accounting"
keelson groups members add accounting [email protected] [email protected]
keelson access show --app my-app
keelson access set --app my-app --view accounting --manage owners --manage admins
keelson access set --app my-app --view none # 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.

Domains and join policies

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.

MethodWhen the person becomes a member
Open a join link shared internallyAfter signing in with an eligible email address and selecting Join. No administrator approval is needed
Request access from the Join or Create a Workspace pageWhen an Owner or Admin approves the request

Both methods assign the App User role. An Owner or Admin can change it afterward.

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.

  1. Open Settings → Security → Team Join Link.
  2. Enable the join link.
  3. 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.

  • Reset link invalidates the current URL and creates a new one. Use this when you want to change where the link is shared.
  • Disabling the join link returns the workspace to invitation-only joining. Existing members are not affected.

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.

PolicyBehavior
Invite only (default)People join through individual invitations from an Owner or Admin
Join linkPeople with an eligible email domain can join through the link without approval. Requests from the Join or Create a Workspace page require administrator approval

IP restrictions

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.

  1. Create access sources in the workspace.
  2. Choose the workspace’s default allowed sources. An empty selection means no IP restrictions.
  3. 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.

Configure an individual app (Owner / Admin)

Section titled “Configure an individual app (Owner / Admin)”

Open the app’s Settings → Security.

ModeBehavior
Inherit Workspace DefaultApplies the default access sources. If the default is empty, there are no IP restrictions
App-specific SourcesReplaces 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.

Plans and limits

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.
StarterPlusTeam
Apps running at once124
Developers (Owner / Admin / Developer seats)123
Background Jobs runs per month5002,5005,000
Background Jobs runtime per month300 minutes1,500 minutes5,000 minutes
Scheduled job definitions per app3510
Minimum interval between scheduled runs60 minutes15 minutes5 minutes
Background task definitions per app101010
Maximum duration per run (scheduled run or background task attempt)3 minutes5 minutes10 minutes
Builds per month1003001,000
Build time per month500 minutes1,500 minutes5,000 minutes
Concurrent builds112
Daily backups retained137
Point-in-time recovery (PITR) window24 hours7 days14 days
Storage (workspace total)10 GB20 GB50 GB
Database size (per database)No fixed limitNo fixed limitNo fixed limit
Email recipients per month5002,00010,000
Priority start (paid subscriptions)—YesYes
Custom domains——Up to 10
Custom email sending domains——Up to 10 (20 allowed sender addresses each)
Access log history30 days90 days180 days
App log history7 days14 days30 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 App Users.
  • 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.
LimitBehavior
Apps running at onceApps 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
DevelopersInvitations 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 runsRemaining scheduled runs for the month are skipped, and new background tasks are rejected (TASK_MONTHLY_QUOTA_EXCEEDED). The allowance resets next month
Background Jobs runtimeRemaining 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 durationDeployment 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 timeNew 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 buildsNew builds are rejected until a running build finishes and capacity becomes available
Email recipientsFurther sends for the month are rejected (MONTHLY_QUOTA_EXCEEDED). The allowance resets next month
Custom email sending domainsAdding another domain or allowed sender address is rejected. Remove one you no longer use first
StorageApps do not stop immediately. Support will contact you about the overage
Database sizeThere 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.

Add-onUnit
Extra storage+50 GB
Extra app running at once+1 slot
Extra Developer+1 seat

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.

See When Apps Run for how apps start and stop.

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.

ItemLimit
Time until the response starts (first response headers)120 seconds; exceeding this returns a timeout (HTTP 504)
Total time for one request, including streaming responses300 seconds
Time between chunks in a streaming response120 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.

Billing and subscriptions

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.

Trial limits and what happens when a trial ends

Section titled “Trial limits and what happens when a trial ends”

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 endWhat happens
Card registeredThe 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.

ChangeTakes effectBilling
UpgradeImmediatelyCredit 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
DowngradeAt the end of the current billing periodNo 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)

Section titled “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.

DataHow to export it
Managed SQLite dataOn 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
Files SDK filesAdd file listing and download functionality to your app, and download files while the app can still run
Media SDK filesCreate links using the file IDs saved by your app. A member with app view permission can sign in and download the files
Source codeFrom Git, or download it from an individual deployment in the Deploys tab
Environment variables and secretsSecret values are not displayed in the console. If needed, retrieve them from where you originally managed them
Access logs and app logsExport 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.

Apps and data after your subscription ends

Section titled “Apps and data after your subscription ends”

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.

  1. When eligible apps are identified, Owners and Admins receive a deletion notice in the console and by email. It includes the scheduled deletion date.
  2. 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.
  3. 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.
  4. 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.

Reference


Keelson Deploy Spec

Spec version: 2026-10-06 / Raw text (for AI agents): /docs/reference/deploy-spec.txt

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 typeConditions to verify
Web app or hybridThe 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 SPAFiles 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 onlyThe 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.


Every deployment requires a keelson.yaml file in the project root directory.

Minimal configuration:

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none

db and db.mode (libsql or none) are required.

Framework-based apps such as Flask require a production server. See Production hardening.

See the keelson.yaml reference for details about every field.


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.

RuntimeLanguageIntended use
python-slimPythonLightweight APIs, text processing, automation, and similar workloads
python-mediaPythonMedia processing; includes image and video libraries
node-slimNode.jsLightweight web apps, APIs, and similar workloads
node-mediaNode.jsMedia processing; includes image-processing libraries
go-slimGoLightweight Go apps
go-mediaGoGo 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.

  • slim — Contains the language runtime and standard libraries only. It builds faster and has a smaller image.
  • media — Adds common system libraries required for image processing, such as Pillow and sharp, and for video processing.

Keelson does not depend on a specific framework. An app can run if its command starts it and it accepts requests as an HTTP server.

Examples include FastAPI, Flask, Express, Next.js, Hono, and Gin.


The following languages and runtimes are not supported:

  • Ruby
  • Java / Kotlin / Scala
  • PHP
  • Rust
  • .NET / C#
  • Elixir / Erlang
  • Swift

Apps that require an unsupported runtime cannot be deployed to Keelson, even after configuration changes that do not replace that runtime.


Keelson provides fixed build and runtime environments. The app must be able to build and start in those environments.

  • OS: Linux
  • CPU: x86_64 (amd64)

Apps run as a non-root user. They cannot use sudo, run apt-get install, or make system-level changes.

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.

PathWritablePersistentPurpose
/dataNo; it cannot be created by your appNoKeelson 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 permissionsNoSource code and dependencies
/tmpYes, temporarilyNoTemporary files
Home directory (/home/appuser)Yes in Python and Node.js runtimesNoRuntime and tool caches and configuration
Other pathsDepends on ownership and permissionsNoUse /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.
  • A web app must listen for HTTP requests on the port specified by the PORT environment variable.
  • It must listen on 0.0.0.0. Requests cannot reach a server bound to 127.0.0.1 or localhost.
  • Keelson terminates HTTPS. The app itself must listen over HTTP.

Keelson does not provide an environment where arbitrary OS packages can be added.

  • -slim runtimes contain only a minimal set of system libraries.
  • -media runtimes contain common libraries required for image and video processing.
  • An app may not work if it needs a system library that is not present in its runtime.
  • Packages cannot be added with apt-get or similar tools because the app runs as a non-root user.
  • 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.

Even when the language runtime is supported, dependencies and system requirements can prevent an app from being deployed.

Dependencies installed by language package managers

Section titled “Dependencies installed by language package managers”

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.

# Python
command: "python app.py"
# Node.js
command: "npm start"
# Go (Keelson builds `./app` during deployment)
command: "./app"

Some packages require C libraries or other system-level dependencies.

  • Commonly supported by -media runtimes: General media-processing libraries such as Pillow, opencv-python, sharp, and ffmpeg-related packages.
  • Potentially unsupported: Packages that depend on a system library not included in the runtime.
PatternReason
Requires apt-get installPackages cannot be added without root privileges
Depends on a specialized C libraryThe library may not be included in the runtime
Requires a GPU inference libraryGPU instances are not provided
Runs a database server such as PostgreSQL, MySQL, or RedisThe app can connect to an external service, but cannot run that server on Keelson
Requires systemd or a background daemonThe process model is different

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.

NameRaw deploy_modecommandassetsDescription
Web appcontainerPresentAbsentStandard app deployment
Static siteedge-staticAbsentPresent, without fallbackStatic files only
SPAedge-spaAbsentPresent, with fallbackSingle-page app with fallback routing
HybridhybridPresentPresent; fallback required when type: web is explicitStatic files plus a backend API
Scheduled jobs onlycontainerAbsentAbsentRequires 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.

Framework static-path collision matrix (reference)

Section titled “Framework static-path collision matrix (reference)”

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.

FrameworkDefault static pathConflicts with /__keelsonStatus
Vite (Vue / Svelte / React / Solid / Preact)/assets/*NoVerified
Remix v2 / VitePress/assets/*Expected not toNeeds verification
Angular/assets/*Expected not toNeeds verification
Next.js/_next/static/*Expected not toNeeds verification
Nuxt / SvelteKit / Astro/_nuxt/* · /_app/* · /_astro/*Expected not toNeeds verification
CRA / Django / Flask/static/*Expected not toNeeds verification

Configure API keys, tokens, and connection details required at startup as environment variables or secrets instead of embedding them in source code.

  • env in keelson.yaml — Values that are safe to include in version control.
  • Console secrets — API keys, tokens, and other values that should not be committed to source code.

The app may fail to start correctly if a required value is missing.

See Environment variables and secrets for details.

VariableDescription
PORTPort the app must listen on. Read it; do not set it
TZWorkspace time zone, detected automatically from the browser when the workspace is created
KEELSON_MODEMarker indicating that the app is running on Keelson (keelson)
KEELSON_APP_IDInternal app ID
KEELSON_WORKSPACE_IDInternal workspace ID
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_IDInternal 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:

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 app:app"

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.

Django: Start Django with gunicorn:

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 config.wsgi:application"
  • Keep DEBUG off by default.
  • 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:

command: "uvicorn main:app --host 0.0.0.0 --port $PORT"

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.

Defaults must be safe for production. In Python, default DEBUG to false:

DEBUG = os.environ.get("DEBUG", "false").lower() == "true"

Do not default it to true:

DEBUG = os.environ.get("DEBUG", "true").lower() == "true"

Debug pages can expose environment variables, including database authentication tokens.

Use KEELSON_MODE, which Keelson sets to keelson, to detect the platform:

ON_KEELSON = os.environ.get("KEELSON_MODE") == "keelson"

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.


TargetLimit
HTTP requestThe 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
Build600 seconds

  • 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.

SymptomCauseResolution
App is running but unreachable, or health checks failServer is not listening on 0.0.0.0Set host="0.0.0.0" explicitly
Cannot connect to the portPort number is hard-codedRead the port from the PORT environment variable
Module not foundDependency is missing from the dependency manifest or failed to install during the image buildCheck the build logs and declare the dependency in requirements.txt or package.json
Native module fails to buildRequired system library is missingSwitch to a -media runtime or replace the dependency
Start command not foundEntrypoint path is incorrectCheck the file name and path
Preflight stops with command_installs_dependenciescommand includes pip install / npm installRemove installation steps and keep only the start command
Validation stops because db is missingNo db.mode is declaredDeclare libsql or none
Build fails with reserved_path_conflictA __keelson directory exists at the root of the served build outputRename the directory. See Reserved URL paths for other restrictions
A scheduled job never fires, and no error is loggedThe schedule lives inside the app process (an in-process scheduler). The app has scaled to zero by the time it is dueDeclare it as a crons entry instead
The UI reports success, but the email or notification never arrivesWork deferred until after the response did not runFinish 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
  1. Check the build logs — Confirm that dependencies were installed successfully.
  2. Check the runtime logs — Look for startup and runtime errors.
  3. Check keelson.yaml — Verify runtime, command, and env.

When deploying an app to Keelson, an AI agent should make decisions in the following order.

  1. Check the runtime — Confirm that the app’s language appears in this specification’s supported runtimes.
  2. Check dependencies — Confirm that native dependencies and system-library requirements fit within the constraints.
  3. Check keelson.yaml — Confirm that the file exists and its configuration is valid. Create it if it is missing.
  4. Check environment variables — Identify any missing environment variables or secrets the app requires.
  5. Deploy — Use the CLI or Skill to run the deployment.
  6. 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.
  7. Handle failure — Inspect the logs and make changes where possible. If a constraint is violated, state the reason and stop.
  • Do not attempt a deployment when the runtime is unsupported. State the reason and stop.
  • When required configuration is missing, explain what is needed.
  • Do not treat build success alone as deployment completion.
  • Always inspect the logs after a startup failure.
  1. The latest Keelson Deploy Spec on the web (this document)
  2. The latest CLI version information on the web
  3. The copy of the specification bundled with a Skill
  4. General knowledge and assumptions

If the web canonical specification conflicts with information bundled with a Skill, follow the web canonical specification.


keelson.yaml

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.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none

FieldTypeRequiredDefaultDescription
slugstringYes—Application identifier; used when the application is created
workspacestringNonullDefault 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
descriptionstringNonullA short description of the application, up to 300 characters
iconstringNonullImage shown as the app’s icon in the console; a path relative to keelson.yaml. See icon
typestringConditionalnullApp type; only web is accepted. Required for static sites and SPAs without a start command
runtimestringYes—Execution environment; see Supported runtimes
commandstring | listConditional—Start command; optional for scheduled jobs only, or static sites with type: web and assets
envmapNo{}Non-secret environment variables
dbobjectYes—Database and local SQLite policy
cronslistConditional[]Scheduled jobs; required without command unless this is a static site with type: web and assets
taskslistNo[]Background tasks that the app enqueues with the SDK; see tasks
mcpobjectNonullTools that AI clients use over MCP; see mcp
storageobjectNo{}Compatibility block that accepts only the deprecated disk_id field
assetsobjectNonullStatic asset delivery configuration
healthobjectNonullHealth check path used during deployment
secretsobjectNo{}Secret declarations and requirements
authobjectNonullEndpoints that bypass interactive Keelson authentication
verifystring | listNo[]Extra paths checked after a deployment becomes healthy
regionstringNonullLogical 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.

When in doubt, quote string values. This ensures the CLI and API read the same string.

The following strings require quotes or a block scalar (| / >). Unquoted values are rejected with string_value_not_portable:

  • Values beginning with a digit, such as slug: "123-app".
  • Values beginning with a non-ASCII character, such as description: "Équipe commerciale".
  • Values beginning with -, including command arguments: command: [python, app.py, "--port", "8080"].
  • Words YAML interprets as booleans or null: yes, no, on, off, true, false, null, and the case variants in Environment variable key constraints.
  • 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 typeUnquoted form allowedExamples
Identifiers such as slug, runtime, or secret namesStarts with an ASCII letter or _my-app, python-slim, OPENAI_API_KEY
Commands, descriptions, paths, and other stringsStarts 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.


The app identifier. It forms part of the app URL assigned at creation: https://<workspace-slug>--<slug>.keelson.run.

slug: my-app

Rules:

  • Use lowercase letters, digits, and hyphens only: a-z, 0-9, and -.
  • The length must be between 1 and 63 characters.
  • The value must start and end with a letter or digit.
  • Consecutive hyphens are not accepted.
  • Reserved slugs are admin, api, assets, auth, console, health, static, and www.

public_slug is a deprecated compatibility field. It is ignored because the public URL is derived from the application host.


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_outcomeMeaning
appliedThe image from keelson.yaml is now the app’s icon
unchangedThe app already uses this image
kept_console_iconAn icon uploaded in the console is kept; keelson.yaml does not replace it
kept_clearedThe 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.

RuntimeLanguageIntended use
python-slimPythonLightweight applications, APIs, and text processing
python-mediaPythonImage and video processing, including Pillow, opencv, and ffmpeg-related libraries
node-slimNode.jsLightweight Node.js applications
node-mediaNode.jsImage processing, including sharp
go-slimGoLightweight Go applications
go-mediaGoMedia-processing Go applications

Start with a -slim runtime unless the application needs the extra media libraries included in a -media runtime.

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.

RuntimeDetected filesBuild step
python-*requirements.txtpython -m pip install --user -r requirements.txt
python-*pyproject.toml with [project] or [build-system], only if requirements.txt is absentpython -m pip install --user .
node-*package-lock.json / package.jsonnpm ci when a lockfile is present, or npm install, followed by npm run build --if-present
go-*go.mod / go.sumgo 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.

  • Go builds use CGO_ENABLED=0.

  • The build target is linux/amd64 only.

  • Secrets are not available during the build.

  • The build execution time limit is 600 seconds.


The command executed when the application starts. Use either a shell string or an argument list.

# String form: executed with /bin/sh -c
command: "python app.py"
# List form: executed directly
command:
- python
- app.py

Rules:

  • Supply command, crons, or a static site configured with both type: web and assets.
  • A cron-only application does not need a top-level command.
  • A static-only type: web application with assets does not need a top-level command.
  • Do not include dependency installation or build steps. Keelson runs them automatically during the image build.
  • Empty strings and empty lists mean no start command. An ordinary web app requires a command. crons[].command cannot be empty.

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:

yes Yes YES no No NO true True TRUE false False FALSE on On ON off Off OFF null Null 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.


Selects how the application handles relational data. The db block and its mode field are required.

db:
mode: libsql
FieldTypeRequiredDefaultDescription
modestringYes—libsql or none; legacy turso is normalized to libsql
migratestringNonullMigration command run during deployment; valid only with libsql
auto_adoptbooleanNofalseAccepted for compatibility but currently does nothing; it does not adopt an existing database
local_sqliteobjectNonullExplicit declaration of regenerable, ephemeral local SQLite files
modeMeaning
libsqlKeelson provisions Managed SQLite dedicated to the app and injects KEELSON_DB_URL / KEELSON_DB_AUTH_TOKEN (aliases: TURSO_DATABASE_URL / TURSO_AUTH_TOKEN)
noneKeelson 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.

Use db.local_sqlite only for a disposable file that can be regenerated. It documents the application policy; it does not make the file persistent.

db:
mode: none
local_sqlite:
policy: ephemeral
paths:
- /tmp/cache.db
reason: "A derived cache rebuilt from the source API"
FieldTypeRequiredConstraint
policystringYesMust be ephemeral
pathslist of stringsYesNon-empty; each entry must be under /tmp/ or be :memory:
reasonstringYesNon-empty explanation of why losing the data is safe

The retired databases block is always rejected. It cannot be used to persist a file SQLite database.


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
FieldTypeRequiredDefaultDescription
namestringYes—Unique job name, 1–63 lowercase letters, digits, or hyphens
schedulestringYes—Valid five-field cron expression
commandstring | listYes—Command to execute
timeoutintegerNo300Timeout in seconds, from 1 through 600
enabledbooleanNotrueWhether Keelson schedules the job
descriptionstringNo—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.

ExpressionMeaning
0 * * * *At minute 0 of every hour
*/15 * * * *Every 15 minutes
0 3 * * *Every day at 03:00
0 9 * * 1-5Weekdays at 09:00
0 0 1 * *The first day of each month at 00:00

* * * * * (every minute) is rejected because it is shorter than every plan’s minimum interval. See Scheduled jobs for execution behavior and design.


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
FieldTypeRequiredDefaultDescription
namestringYes—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
commandstring | listYes—Command to execute. A string runs with /bin/sh -c; a list runs directly
timeoutintegerNo300Time limit for one attempt in seconds, from 1 through 600
max_attemptsintegerNo3Maximum number of attempts, including the first, from 1 through 5
descriptionstringNo—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.

See Plans and limits for timeout limits by plan.


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]
FieldTypeRequiredDefaultDescription
enabledbooleanYes—true publishes the tools. false keeps the declaration but stops calls (existing connections are not revoked)
instructionsstringNonullDescription of the tools as a whole, passed to the AI client. Up to 4096 characters
toolslistConditional—The tools. Required when enabled: true. When written, 1–40 entries regardless of enabled
filestringNo—Separate file holding instructions and tools; see mcp.file. Requires a recent Keelson CLI
tools[].namestringYes—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[].descriptionstringYes—Description shown to the AI client. 1–1024 characters, not only whitespace
tools[].accessstringYes—read or write. write tools are shown separately on the consent screen, and users can approve read-only access
tools[].permissionstringNoviewview or manage. A manage tool can be used only by people with the app’s manage permission
tools[].inputobjectYes—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.

# Invalid: deployment is rejected
workers:
- name: drain
command: "python worker.py"
every: 10m
# Valid: use crons
crons:
- name: drain
schedule: "*/5 * * * *"
command: "python drain.py"
timeout: 120

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.

FieldTypeRequiredDefaultDescription
disk_idstringNonullDeprecated 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
FieldTypeRequiredDefaultDescription
dirstringYes—Asset directory relative to the project root
static_dirstringNonullDeprecated alias of dir; if both are present, they must match
fallbackstringConditionalnullSPA fallback, such as index.html, relative to dir. Required for hybrid apps with type: web
apistringNonull (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.

Customizing exclusions with .keelsonignore

Section titled “Customizing exclusions with .keelsonignore”

Create .keelsonignore in the project root for project-specific exclusions. .gitignore is not read.

SyntaxMeaning
One pattern per lineExclude 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.

JSON fieldWhat it reports
archive.excludedExcluded files
archive.excluded_env_filesExcluded env files
archive.secret_like_filesFiles suspected of containing secrets based on their names or other indicators
archive.embedded_credentialsPaths and types of credentials detected inside files; values are never shown
archive.unscanned_filesFiles whose contents were not scanned, for example because of size limits
archive.unscanned_binary_countNumber 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.


Sets the deployment health check path. Use a lightweight endpoint if / is expensive or depends on the database.

health:
path: /health
  • Defaults to /.
  • Must start with /, contain no .., not start with /__keelson, and be at most 2,048 characters.
  • HTTP 2xx, 3xx, and 404 pass; other 4xx and all 5xx fail. Because 404 passes, success proves the process started, not that the route exists.
  • The probe goes directly to the candidate revision’s internal URL, bypassing the public host and authentication. Use verify to check public paths.

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.

secrets:
items:
- name: OPENAI_API_KEY
description: "OpenAI API key"
- name: ANTHROPIC_API_KEY
description: "Anthropic API key"
required:
- any_of: [OPENAI_API_KEY, ANTHROPIC_API_KEY]
message: "Set at least one AI provider key"
FieldTypeRequiredDefaultDescription
itemslistNo[]Secret definitions
items[].namestringYes—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[].descriptionstringNo""Human-readable purpose of the secret
requiredlistNo[]Rules checked against configured secrets
required[].any_oflistConditionalnullNonempty list of names; at least one must be set
required[].all_oflistConditionalnullNonempty list of names; all must be set
required[].messagestringNo""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.

PathRequired 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:

KeyDisplay nameNew apps
jp-tokyoJapanAvailable (default)
us-oregonUS WestNot 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.

NameRaw deploy_modecommandassetsfallbackDescription
Web applicationcontainerPresentAbsent—Ordinary application deployment
Static siteedge-staticAbsentPresentAbsentStatic files only
SPAedge-spaAbsentPresentPresentStatic files with a fallback
HybridhybridPresentPresentRequired with type: webStatic files plus a backend API

Some fields are valid only in specific combinations.

RuleRequirement
A start command, scheduled jobs, or a static site is requiredSupply command, crons, or both type: web and assets
Tasks need code that enqueues themWhen tasks is present, supply command or crons (tasks_require_command_or_crons)
Task and job names are separateA tasks[].name cannot match a crons[].name (task_name_conflicts_with_cron)
Static sites cannot use scheduled jobsCombining type: web and crons fails configuration validation. Omit type when using scheduled jobs
Static sites have no containerdb.migrate does not run for static sites or SPAs. Use a container or hybrid deployment for migrations
workers is retiredReplace every workers declaration with tasks (work that runs after a request) or crons (time-triggered work)
Managed migration requires managed datadb.migrate requires db.mode: libsql
Local SQLite is disposabledb.local_sqlite.paths accepts only /tmp/ paths and :memory:
mcp requires an app that runs server-side codemcp can be written only with command and without assets (mcp is only supported for container apps)
assets.api requires a backendSet a top-level command before using an API prefix
Hybrid deployments with type: web require a fallbackSet assets.fallback when type: web, assets, and command are present. This check does not apply if type is omitted

slug: crud-app
description: "An internal CRM for managing customers and deals"
runtime: python-slim
command: "python app.py"
db:
mode: libsql
migrate: "python migrate.py"
env:
PYTHONUNBUFFERED: "1"

Run a local build such as npm run build to create dist before deployment. Keelson does not build a static site.

slug: marketing-site
type: web
runtime: node-slim
db:
mode: none
assets:
dir: dist
fallback: index.html
slug: daily-report
runtime: python-slim
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: generate
schedule: "0 9 * * *"
command: "python report.py"
timeout: 120

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.

slug: pdf-reports
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
tasks:
- name: generate-pdf
command: "python generate_pdf.py"
timeout: 180
max_attempts: 3

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.

slug: task-drainer
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: drain
schedule: "*/5 * * * *"
command: "python drain.py"
timeout: 120
slug: photo-galleries
runtime: node-slim
command: "node server.js"
db:
mode: libsql
env:
NODE_ENV: "production"
assets:
dir: dist
fallback: index.html
api: /api
slug: my-go-app
runtime: go-slim
command: "./app"
db:
mode: none

Keelson builds the Go source for Linux and places the binary at ./app during deployment.

slug: payment-hooks
runtime: node-slim
command: "npm start"
db:
mode: libsql
env:
NODE_ENV: "production"
secrets:
items:
- name: STRIPE_WEBHOOK_SECRET
description: "Stripe webhook signing secret"
required:
- all_of: [STRIPE_WEBHOOK_SECRET]
auth:
endpoints:
- path: /api/webhooks/stripe
methods: [POST]

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.


CauseResolution
db or db.mode is missingDeclare db.mode as libsql or none
The retired databases key is presentRemove it; use Managed SQLite or declare disposable local SQLite explicitly
A local SQLite path points outside /tmp/Use /tmp/, :memory:, or Managed SQLite for durable data
assets.api does not start with /Use an absolute path prefix such as /api
No start command, scheduled jobs, or static site configurationAdd command, crons, or both type: web and assets
workers is declaredRemove it; workers are rejected with workers_not_supported. Use tasks for work that runs after a request and crons for time-triggered work
crons[].timeout is greater than 600Reduce the timeout to the platform ceiling or lower plan limit
tasks is present without command or crons (tasks_require_command_or_crons)Add the command of the web app that enqueues the tasks, or a job in crons
A task has the same name as a scheduled job (task_name_conflicts_with_cron)Rename the task or the job
tasks[].timeout is greater than 600 (TASK_TIMEOUT_EXCEEDS_STANDARD_LIMIT)Reduce it to 600 or less, and to the plan limit (plan_task_timeout), or split the work into smaller tasks
mcp.enabled is true but mcp.tools is missingDeclare at least one tool, or move the tools into a separate file with mcp.file
A static site, SPA, or hybrid app has mcp (mcp is only supported for container apps)Remove mcp, or make the app a command-only app
mcp.tools[].input contains $ref or x-mcp-headerWrite the schema out in full. Do not use $dynamicRef or $recursiveRef either
mcp contains unsupported field(s): fileThe Keelson CLI is too old. Run keelson upgrade to get the latest version
An env value is unquotedQuote every value, including booleans and numbers
An environment variable starts with KEELSON_Rename it; that namespace is platform-owned
A hybrid deployment with type: web omits assets.fallbackSet a fallback file such as index.html
command installs dependencies (command_installs_dependencies)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_conflictRename 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.

VariableDescription
PORTPort the app listens on. Read this value; do not set it
TZWorkspace time zone (for example, Asia/Tokyo)
KEELSON_MODEkeelson 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_IDInternal app ID
KEELSON_WORKSPACE_IDInternal workspace ID
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_IDInternal ID of the current deploy
KEELSON_APP_URLApp 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_URLDirectory 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.

The Files / Media / Identity / Tasks / Email SDKs read their connection settings and credentials automatically. App code generally does not need to access these values directly.

VariableConditionDescription
KEELSON_DB_URLdb.mode: libsqlManaged SQLite connection URL
KEELSON_DB_AUTH_TOKENdb.mode: libsqlManaged SQLite authentication token
TURSO_DATABASE_URL / TURSO_AUTH_TOKENdb.mode: libsqlAliases for the two variables above (for compatibility)
KEELSON_FILES_BUCKET / KEELSON_FILES_PREFIXContainer deployments with file storage configured (automatic for standard deployments)Storage location used by the Files SDK
KEELSON_INTERNAL_MEDIA_BASE_URL / KEELSON_APP_MEDIA_TOKENContainer deployments with Media enabled (automatic for standard deployments)Upload destination and authentication token used by the Media SDK
KEELSON_MEDIA_URL_PREFIXSame condition as the Media SDK upload destination and tokenMedia delivery URL prefix (/__keelson/media/)
KEELSON_DIRECTORY_TOKENAfter keelson apps directory enable and redeploymentDirectory API token used by the Identity SDK. Handle only on the server
KEELSON_EMAIL_API_URL / KEELSON_EMAIL_TOKENContainer deployments (automatic for standard deployments)Endpoint and authentication token the Email SDK uses to send email. Handle only on the server
KEELSON_EMAIL_WEBHOOK_SECRETSame condition as KEELSON_EMAIL_TOKENKey for verifying the signature of email delivery result notifications. Pass it to the Email SDK’s verification function
KEELSON_TASKS_BASE_URLSet 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.

  • NODE_ENV — Not set automatically. Declare "production" in env in keelson.yaml
  • External database connection details — Pass them as secrets. With db.mode: none, Managed SQLite connection details are not set automatically

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.

VariableDescription
KEELSON_MODE=localUse 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_DIRLocal storage location for the Files SDK (default: ./.keelson/files)
MEDIA_DIRLocal storage location for the Media SDK (default: ./media)
KEELSON_LOCAL_MODE=1Make 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_FILEUser list file the Identity SDK reads when KEELSON_LOCAL_MODE=1 (default: ./.keelson/dev-users.json)
KEELSON_LOCAL_USER_ID / KEELSON_LOCAL_USER_EMAIL / KEELSON_LOCAL_USER_NAMEID, email address, and display name of the test user returned by the Identity SDK. Not used when a user list file exists
KEELSON_LOCAL_WORKSPACE_ID / KEELSON_LOCAL_WORKSPACE_ROLE / KEELSON_LOCAL_APP_IDTest 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.

CLI commands

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.

FlagDescription
--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
--jsonOutput the result as JSON. Use this from scripts and agents
--quietSuppress 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-browserShow 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.

CommandDescription
keelson login [--no-browser]Log in through a browser and link this device
keelson logout [--all-devices]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 whoamiShow 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
keelson install-agent <claude-code|codex|cursor> [--global]Install the Skill for an AI agent. With no argument and --yes, update all installed Skills
keelson upgradeUpdate the CLI to the latest version
keelson versionShow the version
keelson telemetry status|off|onShow, disable, or enable CLI usage telemetry
keelson feedback --app <slug> [--deploy <id>] < feedback.jsonSend 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.

CommandDescription
keelson deployDeploy the app in the current directory. See below for when it waits for completion
keelson deploy --new [--region <region>] [--secrets-from-env-file <path>]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 --yesStream 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
InvocationBehavior
keelson deploy in an interactive terminalWaits for completion; default 30 minutes, configurable with --watch-timeout
keelson deploy --yes in a pipe, CI, or other non-interactive environmentReturns when accepted. Use --yes --watch to wait
--jsonReturns when accepted; cannot be combined with --watch
--ndjsonWaits for completion; cannot be combined with --no-watch
--no-watchReturns 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.
CommandDescription
keelson app curl <path> [-i] [--method <method>] [--data <body|@file>] [--form <name=value|name=@file>] [--header <name:value>]Send an authenticated request to the app. See Verify a deploy
keelson preview [--ttl <duration>] [--allow-writes] [--json]Issue an expiring token for authenticated access to the app
keelson app info / start / stop / restartShow 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.

CommandDescription
keelson logs app <slug> [--mode live|history] [--severity <level>] [--previous] [--limit <n>] [--since <duration>](--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
keelson logs cron <slug> [--severity <level>] [--limit <n>] [--since <duration>]Show scheduled job execution logs
keelson logs access [--limit <n>] [--since <duration>]Show access logs
keelson logs deploy [deploy_id] [--limit <n>] [--since <duration>]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.

CommandLogs in JSON
logs app / logs cronentries: objects containing the timestamp, severity, message, and other fields
logs accessentries: objects containing the timestamp, HTTP status, path, and other fields
logs deploylogs: progress log objects. failure_logs and startup_logs: arrays of strings

Pass values through standard input or an env file, not as command arguments.

CommandDescription
keelson secrets listShow configured names and update times, without values
echo -n "<value>" | keelson secrets set <KEY> [--apply]Set one value. --apply also requests redeployment and returns when accepted
keelson secrets set --from-env-file <path> [--apply]Set multiple values from an env file. Keys are saved individually; a failure partway through leaves a partial update. The error lists saved keys
keelson secrets unset <KEY>Delete a secret

Secrets take effect on the next deploy, or when you use --apply.

CommandDescription
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
keelson apps duplicate <slug> --name <new> [--copy-secrets] [--enable-schedule]Duplicate an app; scheduled jobs are disabled by default
keelson apps rename <new_slug>Change the slug in the app URL. The old URL returns 410 without an automatic redirect. The change takes time to propagate
keelson apps delete [slug]Delete an app. Browser approval is required
keelson quotasShow plan limits and usage

After apps rename, update slug in your local keelson.yaml too.

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.

ItemWhat happens
Application codeCopied from the latest successful deploy
Configuration (keelson.yaml)Copied, with the duplicate’s new app identity
Scheduled job definitionsCopied, but created disabled after the duplicate’s deploy succeeds. --enable-schedule enables jobs unless their configuration explicitly sets enabled: false
App-level secretsNot copied by default. Use --copy-secrets to copy them
Workspace-level secretsNot copied, but they remain available because workspace-level secrets are provided to every app in the workspace
Platform-managed credentialsNew credentials are issued for the duplicate
Managed database contentsNot copied; the duplicate gets a new, empty database. The CLI has no option to copy database contents
Deploy historyNot copied; the duplicate starts with a new deploy based on the source app’s latest successful deploy
CommandDescription
keelson crons list [--include-disabled]List jobs
keelson crons trigger <name> [--wait]Run a job now; --wait waits for its result
keelson crons enable <name> / disable <name>Enable or disable a job
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
CommandDescription
keelson dev serve [--port <n>] [--users <file>] [--as <id>] -- <command...>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 --checkExit with code 0 without starting anything. Use it to check whether this CLI supports dev serve
CommandDescription
keelson db apply [file.sql]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 listList restore points and running or recent exports, including successful and failed exports
keelson snapshots createCreate 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-statusCheck 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]Recover credentials and show their status
CommandDescription
keelson groups listList groups
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:

Terminal window
keelson access set --app my-app --view sales --view engineering

A change that removes your own manage permission requires --allow-self-lockout.

CommandDescription
keelson apps tokens create --app <slug> --name <name> --scope <scope> [--scope <scope>]... [--allowed-ip <cidr>]...Issue an app token, shown only once. --app is required
keelson apps tokens listList token metadata only
keelson apps tokens rotate --app <slug> --token-id <id>Rotate a token; the old token expires after a grace period
keelson apps tokens delete <token_id>Revoke a token
keelson apps directory enableIssue a Directory API token and register it as KEELSON_DIRECTORY_TOKEN (redeploy required)
keelson domains listList custom domains and their status
keelson domains add|verify|remove <hostname> [--yes]Add, verify, or remove a custom domain (Team). Non-interactive remove requires --yes

Allowed --scope values are listed below. Inbound and Directory scopes cannot be combined in one token.

ScopePurpose
apiCall the app’s /api/external/... endpoints from external systems
webhookReceive webhooks from external services
directory:members:read / directory:identity:read / directory:groups:read / directory:group-members:readCall the Directory API from the app; normally granted by apps directory enable

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.

FieldDescription
verdict (required)Object with required score (integer 1–10) and summary (1–1,000 characters)
frictionUp to 20 objects with step, command, output_quote, expected, lost_minutes, and recovered
yaml_confusions / error_message_reviews / wanted_commands / kept_goodUp to 20 strings each
skill_gaps / verification_methodStrings

Each friction entry requires all six fields:

FieldType and meaning
stepString describing the task stage or situation where friction occurred
commandString containing the command run
output_quoteString quoting the message displayed
expectedString describing the expected behavior
lost_minutesInteger from 0 to 1,440: minutes spent resolving the problem
recoveredBoolean: true if resolved, false if unresolved

For example, save the following as feedback.json and submit it. Replace the content with your actual experience.

{
"verdict": {
"score": 8,
"summary": "Deployment succeeded, but confirmation in non-interactive execution was unclear."
},
"friction": [
{
"step": "Deploying from CI",
"command": "keelson deploy",
"output_quote": "The confirmation response could not be read from standard input.",
"expected": "I wanted to know the required flags for non-interactive execution in advance.",
"lost_minutes": 3,
"recovered": true
}
]
}

These older names still work and no removal date is set. Prefer the canonical form; the alias is not shown in --help output as a separate command.

AliasCanonical form
keelson backups list / trigger / download / restorekeelson snapshots list / create / download / restore
keelson tokens list / deletekeelson apps tokens list / delete
keelson skill install [--global] [--codex] [--yes]keelson install-agent <claude-code|codex|cursor> [--global] [--yes]
keelson tenants listkeelson workspaces list
keelson db rotate-credentialskeelson db revoke-tokens
--tenant--workspace

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.

VariableDescription
KEELSON_TOKENToken used instead of login, for example in CI. Takes precedence over saved credentials. Setting it to an empty string causes an error
KEELSON_NO_UPDATE_CHECK=1 / KEELSON_NO_SKILL_SYNC=1Disable update checks / automatic Skill updates. Any nonempty KEELSON_NO_SKILL_SYNC value, including 0, disables sync
KEELSON_NO_TELEMETRY=1Disable CLI usage telemetry
KEELSON_CONFIG_DIRDirectory containing credentials.json and config.json
KEELSON_API_BASE_URL / APP_DOMAIN_BASEOverride 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.

LocationHow to inspect the error
ConsoleOpen the app overview or deployment history to see the failure reason and logs
CLIRead 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 APIMessages 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 gatewayBrowsers 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 gatewayHTTP 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 edgeHTTP 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 deliveryInspect 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.

CodeCauseResolution
missing_dependency_manifestNo dependency manifest for the selected runtimeFor 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_dependenciescommand contains pip install / npm install / go buildRemove it and leave only the start command
runtime_command_mismatchThe languages of runtime and command do not match (for example, node with python-slim)Correct either one
assets_dir_emptyassets.dir is missing or empty, or the path uses the wrong letter caseRun the build and check the path
assets_fallback_missingThe assets.fallback file is not in assets.dirCheck 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 fineFollow hint. A common fix is to use a libSQL client that reads KEELSON_DB_URL
file_wiring_fail (warning)State files or uploads are written to local diskUse the Files / Media SDK
icon.pathThe icon file is missing, unreadable, a symbolic link, or excluded from the uploadPut the image inside the project where it is uploaded, and set icon relative to keelson.yaml. See icon
icon.format / icon.size / icon.dimensions / icon.corruptThe icon image is not PNG, JPEG, or WebP; is larger than 256 KB; is outside 32 to 2048 pixels on a side; or has an unreadable headerExport the image again as PNG, JPEG, or WebP within the limits

The following errors prevent deployment.

CodeCauseResolution
invalid_keelson_configInvalid 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 errorCorrect the issue described in message
config_missing_fieldA required field such as slug, runtime, or db is missing; static sites also require type and assetsAdd the missing field
env_value_not_stringNon-string env values, ambiguous YAML keys, duplicate keys, or merge keys (<<)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_longdescription is longer than 300 charactersShorten it
workers_not_supportedworkers: is presentRemove it. Use tasks for work that runs in response to a request and crons for time-triggered work
DB_DATABASES_REMOVEDdatabases: is presentRemove it. Use db.mode: libsql for persistent data
SQLITE_LOCAL_SQLITE_INVALID_PATHA db.local_sqlite.paths value is outside /tmp/Use /tmp/ or :memory:
CRON_TIMEOUT_EXCEEDS_STANDARD_LIMITA crons[].timeout value exceeds 600 secondsReduce it to 600 seconds or less, or split the operation
plan_cron_limit / plan_cron_min_interval / plan_schedule_timeoutJob count, interval, or timeout violates plan limitsReduce the count, increase the interval, shorten the timeout, or choose a plan that supports the required settings
tasks_require_command_or_cronstasks is declared, but there is neither command nor crons, so no code can enqueue the tasksAdd the command of the web app that enqueues the tasks, or a job in crons
task_name_conflicts_with_cronA tasks[].name matches a crons[].name after removing surrounding whitespace and lowercasingRename the task or the job
TASK_TIMEOUT_EXCEEDS_STANDARD_LIMITA tasks[].timeout value exceeds 600 secondsReduce it to 600 seconds or less, or split the work into smaller tasks
plan_task_timeoutAn explicit tasks[].timeout exceeds the plan’s limit per runShorten 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_DRIVERA file-based SQLite client such as sqlite3 or better-sqlite3 was detectedMigrate 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.

These are warnings and do not fail deployment by themselves. Respond according to the purpose of the code.

CodeCauseResolution
ARTIFACT_EXEC_NODE_CRON / ARTIFACT_EXEC_APSCHEDULERScheduled work is registered inside the app processIt will not run while the app sleeps. Extract a standalone command and declare it in crons in keelson.yaml
ARTIFACT_EXEC_BACKGROUND_TASKSWork runs after the response is returnedawait 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_INTERVALServer-side code uses setIntervalNo 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.

CodeCauseResolution
deploy.config.invalidInvalid keelson.yaml contentCorrect the reported key and deploy again
deploy.config.secret_missingA secret declared in secrets.required is not setSet every secret with keelson secrets set <KEY> (the value is read from standard input), then deploy again
deploy.artifact.invalidUploaded archive failed validation, for example because a required lockfile is missingFix the files or code identified in the error
deploy.artifact.assets_invalidFiles declared by assets.dir or assets.fallback are missing from the uploadBuild locally and redeploy
deploy.build.failedDependency resolution, a build script, or a reserved-path collision caused a build failureRead the end of the build output with keelson logs deploy <deploy_id>
deploy.build.timed_outBuild exceeded its time limitRemove unnecessary dependencies or move expensive preprocessing out of the build
deploy.build.image_too_largeThe image is too largeRemove unnecessary dependencies or files
deploy.runtime.start_failedThe app exited during startup or the container did not reach the running stateRead 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 / 5xxRead stored startup logs with keelson logs deploy <deploy_id>; check the listening port and health.path
deploy.runtime.migration_faileddb.migrate exited nonzero; the previous revision continues servingFix the migration and redeploy. Partially applied database changes are not automatically undone
deploy.verify.failedA verify path is unreachable, or JavaScript / CSS Content-Type does not matchCheck paths and build output. Details appear as verification_* codes in failure_logs
deploy.plan.limit_exceededA plan limit was exceededIdentify the limit in failure_hint, adjust settings or usage, or choose a plan with the required limit
deploy.plan.cron_count_exceededThe app declares more crons than the plan allowsReduce the number of crons or upgrade the plan
deploy.plan.cron_interval_too_shortA cron fires more often than the plan’s minimum intervalIncrease the interval or upgrade the plan
deploy.plan.schedule_timeout_exceededAn explicit crons[].timeout exceeds the plan’s ceilingLower the timeout or upgrade the plan
deploy.app.suspendedKeelson has restricted the app or workspaceContact support. A user or administrator cannot remove this restriction by resuming the app
deploy.app.operation_in_progressThe deploy could not start because another operation was in progress for this appWait for the other operation to finish, then deploy again
deploy.platform.temporarily_unavailableTemporary outageWait and retry up to three additional times. If it still fails, contact support with deploy_id
deploy.platform.errorPlatform errorRetrying will not resolve it. Contact support with deploy_id
deploy.cancelledDeployment was cancelledRedeploy if needed
deploy_in_progress (409)Another deploy or app operation is in progressWait for the operation to finish, then run the same command again
build_rate_limit_exceeded / build_guardrail_exceeded (429)The limit for build count, build time, concurrent builds, or consecutive failures was exceededThe 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 schemaCorrect it and perform a normal deploy

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.

CodeMeaning
cron.failed.command_failedCommand exited nonzero
cron.failed.timed_outExceeded timeout
cron.failed.terminated_no_callbackExecution ended without reporting a result
dispatch_failedCould not start execution
cron.skipped.schedule_disabledJob is disabled
cron.skipped.quota_exceededPlan execution allowance exceeded
cron.skipped.due_to_lock / cron.skipped.lease_heldPrevious run has not finished
cron.skipped.missed_runSkipped because the scheduled time had passed
cron.skipped.app_suspendedApp is suspended
cron.skipped.plan_downgradeA plan change made the job ineligible to run

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.

CodeMeaningWhen it occursResolution
TASK_INVALID_REQUESTThe 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 malformedenqueue, on Keelson and locallyFix the arguments. Retrying the same call does not help
TASK_NOT_DECLAREDThe task name is not in the currently deployed tasks (locally: not in keelson.yaml), including a name removed from tasksenqueue, on Keelson and locallyDeclare the task in tasks and deploy, or correct the name
TASK_NOT_FOUNDThe task_id does not exist or belongs to another app. Locally, get knows only tasks enqueued in the same processget, on Keelson and locallyCheck the task_id your app saved
TASKS_UNAVAILABLETask intake is closed, or the app is suspended, quarantined, or being deleted. Retrying does not helpenqueue, on Keelson onlyCheck the app’s status in the console. For a restricted app, contact support
TASK_PAYLOAD_TOO_LARGEThe request body, payload and idempotency_key serialized as JSON, exceeds 65,536 bytesenqueue, on Keelson and locallyStore large input in the database or with the Files SDK and pass its ID in payload
TASK_BACKLOG_LIMIT_EXCEEDEDThe app already has 1,000 tasks waiting to start (queued)enqueue, on Keelson onlyWait until waiting tasks start, or enqueue less often. Tasks already enqueued are not affected
TASK_MONTHLY_QUOTA_EXCEEDEDThe workspace’s monthly Background Jobs allowance cannot cover the first attemptenqueue, on Keelson onlyIntake 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_TRANSIENTA temporary outage: 502 / 503 / 504 without an error body, a connection failure, or the 15-second timeout per callenqueue and get, on Keelson onlyThe 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_UNAUTHORIZEDThe runtime API rejected the identity token (401)enqueue and get, on Keelson onlyContact support if it persists
TASKS_FORBIDDENCloud Run rejected the call (403). Right after the first deploy that declares tasks, the permission can take a few minutes to take effectenqueue and get, on Keelson onlyWait a few minutes after the first deploy with tasks, then try again. Contact support if it persists
TASKS_SERVER_ERRORAnother 5xx response without an error bodyenqueue and get, on Keelson onlyRetry later. Use an idempotency_key when retrying enqueue
TASKS_HTTP_ERRORAnother unsuccessful response without an error bodyenqueue and get, on Keelson onlyContact support if it persists
TASKS_UNEXPECTED_RESPONSEA successful response did not have the documented shapeenqueue and get, on Keelson onlyUpdate the SDK. Contact support if it persists
TASKS_IDENTITY_TOKEN_ERRORNo identity token could be obtained from the metadata serverenqueue and get, on Keelson onlyContact support if it persists
TASKS_NOT_CONFIGUREDThe 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_IDenqueue and getOn 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_FOUNDkeelson is not on PATHenqueue, locally onlyInstall the CLI with https://keelson.dev/install.sh
TASKS_LOCAL_CLI_FAILEDThe CLI could not run the task, for example because it is too old to have keelson dev task run, or it was interruptedenqueue, locally onlyRun 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.

CodeMeaningAttempt countedRetried
exit_nonzeroThe command exited with a nonzero codeYesYes, while attempts remain
timed_outThe attempt exceeded timeout and Keelson stopped the commandYesYes, while attempts remain
no_completionThe attempt ended without reporting its result before the deadlineYesYes, while attempts remain
delivery_failedThe attempt could not start within one hourNoNo
manifest_unresolvableThe task waited across two or more deploys, and the declaration it was enqueued with is no longer availableNoNo
quota_exhaustedA retry, or an attempt starting in a new month, could not reserve the monthly Background Jobs allowanceNoNo

There is no manual rerun. To run the work again, enqueue it again from the app.

These errors come from keelson snapshots .... Codes starting with snapshot_export_ occur in snapshots export.

CodeMeaningResolution
snapshot_plan_not_includedPlan does not include manual snapshots (all plans currently include them, so this should not normally occur)Contact support
snapshot_no_managed_dbApp does not use Managed SQLiteDeploy with db.mode: libsql to create a DB
snapshot_db_name_invalid / snapshot_db_name_unknownInvalid or nonexistent DB nameUse a name shown in hint
snapshot_already_running / snapshot_export_already_runningPrevious operation is runningWait for it to finish, then retry
snapshot_rate_limited / snapshot_export_rate_limitedRequest count limit reachedWait until the time in hint
snapshot_export_invalid_timestampTimestamp is outside the restorable rangeChoose a timestamp within the range
snapshot_app_deleting / snapshot_export_app_deletingApp is being deleted—
snapshot_launch_failed / snapshot_export_launch_failedOperation could not startRetry; contact support if it persists
snapshot_disabled / snapshot_storage_unconfigured (also applies to corresponding snapshot_export_ codes)Feature is disabled or storage is unconfiguredContact support
snapshot_not_readyDownload is not ready (X-Keelson-Error-Code header)Wait for completion
CodeMeaningResolution
not_logged_inNot logged inRun keelson login
multiple_workspaces / workspace_ambiguous / workspace_not_foundWorkspace cannot be identifiedSupply --workspace <slug>; search with keelson workspaces list --query <text>
no_workspacesNo available workspaceCreate a workspace in the console
app_not_foundThe app was not foundCheck the slug. For a new app, use --new
app_manage_requiredThe app exists, but you do not have manage permissionAsk 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_deletingThe app is being deletedWait for completion
forbiddenYou do not have permission. CONSOLE_ONLY_OPERATION identifies an operation limited to the consoleCheck the role and app permissions
confirmation_expired / confirmation_rejectedApproval expired or was rejected; expiry is 15 minutes for delete / SQL / restore and 30 minutes for preview / app curlRun the command again
deploy_failedDeploy failedRun keelson diagnose <deploy_id>
sql_failedSQL in db apply failed and was rolled backCorrect 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 permissionRetain a manage group. --allow-self-lockout only permits removing yourself; it cannot override the requirement for manage groups and effective managers
transientTemporary network errorSpecify 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.

confirmation_required means the operation needs browser approval. Open the displayed URL and approve it.

  • Delete, SQL, and restore execute when approved; do not rerun the command.
  • For preview and app curl, rerun the same command after approval with --confirmation <id>.

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.

DisplayStateResolution
Login screenNot logged inLog in with an account registered in the workspace
”Access denied” (403)The user is not a member, is blocked, or lacks app view permissionAsk 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 IPAsk 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 reachedA 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 sleepWait for the page to change automatically
”This application has been suspended” (503)Suspended by an administrator or restricted by KeelsonAsk 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 publicationCheck 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 daysAsk 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 statusCauseAction for the app developer
504App did not start returning response headers within 120 seconds; this is not the total streaming duration limitReduce work before the response starts. Use crons if the work can run at a scheduled time or interval
501Keelson does not support WebSocketUse SSE or polling

External integrations (app tokens / Webhook)

Section titled “External integrations (app tokens / Webhook)”

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-ErrorCause
invalid-app-tokenThe token is invalid or revoked
machine-scope-deniedToken lacks the api scope
machine-endpoint-deniedThe path or method is not declared in auth.endpoints
machine-forbidden / webhook-forbiddenGateway cannot find the app route, or the token belongs to another app
machine-ip-not-allowedThe request came from outside the app token’s allowed IP ranges
invalid-webhook-secretThe Webhook secret does not match
webhook-scope-deniedThe token does not have the webhook scope
webhook-ip-not-allowedThe 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.

  • Subcommand name
  • 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.
  • Your app’s source code or the contents of deployment archives
  • The contents of logs, standard output, or standard error
  • Environment variable values or values passed to flags
  • File paths, workspace names, or app names
  • The contents of keelson.yaml
  • Conversations with an AI agent

To save your preference, run:

Terminal window
keelson telemetry off

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.