# Project Actions

An action is a TypeScript file `actions/<name>.ts` in a project. Its default export is an ordinary async function: the app imports and calls it directly, and once a `v*` release tag is built, any authorized caller can run the same function remotely in a sandbox with `POST https://git.fullstacked.cloud/api/v1/projects/:id/actions/:name/run` (MCP: `projects_run_action`). Remote runs get the caller's environment and token, so data access follows the caller's permissions.

## File contract

```ts
// actions/add-todo.ts
import type pg from "pg";
import { getPool } from "../db";

export const action = {
    title: "Add Todo",
    description: "Creates a todo item",
    parameters: {
        type: "object",
        properties: {
            title: { type: "string", minLength: 1, description: "Todo title" },
            priority: { type: "string", enum: ["low", "medium", "high"], default: "medium" },
        },
        required: ["title"],
        additionalProperties: false,
    },
};

type Params = { title: string; priority?: "low" | "medium" | "high" };

/** Remote runs only: runs first, its return value is the handler's ctx. */
export async function setup(params: Params) {
    return { pool: await getPool() };
}

/** The handler: called by the app directly (no ctx) and by remote runs. */
export default async function addTodo(params: Params, ctx?: { pool: pg.Pool }) {
    const pool = ctx?.pool ?? (await getPool());
    const { rows } = await pool.query(
        "INSERT INTO todos (title, priority) VALUES ($1, $2) RETURNING *",
        [params.title, params.priority ?? "medium"]
    );
    return rows[0];
}

/** Remote runs only: always runs after the handler, even when it threw. */
export async function teardown(ctx?: { pool: pg.Pool }) {
    await ctx?.pool.end();
}
```

| Export | Required | Rules |
| :--- | :--- | :--- |
| `export const action` | Yes (the release build fails without it) | A **static literal**: objects, arrays, strings (template strings without `${}`), numbers, booleans, `null`, comments, trailing commas; a type annotation, `as const` and `satisfies` are allowed. No identifiers, calls, spreads or shorthand properties. Fields: `title?` (defaults to the name), `description?`, `parameters?` (a JSON Schema whose root is `{ "type": "object" }`, default no properties), `name?` (must equal the file name). |
| `export default` | Yes | `(params, ctx?) => result`. Receives validated params with schema defaults applied. The result must be JSON-serializable (`undefined` becomes `null`). |
| `export function setup(params)` | No | Remote runs only. Opens resources; returns `ctx`. |
| `export function teardown(ctx)` | No | Remote runs only. Always runs, bounded by 5 s (`ACTION_TEARDOWN_TIMEOUT_MS`); its errors are logged and never change the outcome. |

File rules:

- Only top-level `actions/*.ts` files are actions. Files starting with `_` or `.`, `*.d.ts`, subdirectories and other extensions are helpers (`actions/_shared.ts`).
- The file name is the action name: `^[a-z0-9][a-z0-9_-]{0,63}$`. Any other `actions/*.ts` name fails the build.
- Never run the handler at module top level: remote runs import the module, so top-level side effects run too. Detect a remote run with `process.env.FULLSTACKED_ACTION_RUN === "1"` if needed.
- Hooks are plain exports and can be shared: `export { setup, teardown } from "./_lifecycle";`.

## Parameter schema

Supported keywords: `type` (`string`, `number`, `integer`, `boolean`, `object`, `array`, `null`, or a list), `properties`, `required`, `additionalProperties`, `items`, `enum`, `const`, `default`, `minLength`, `maxLength`, `pattern`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minItems`, `maxItems`. Other keywords are ignored. Invalid params answer `400 INVALID_PARAMS` naming the parameter, before anything runs. `required` is an array on the object schema, not a flag on each property.

## Calling from the app

```tsx
import addTodo from "./actions/add-todo";

async function onSubmit(title: string) {
    const todo = await addTodo({ title });
}
```

In the app no schema validation or hooks run: the handler gets exactly what you pass, and resources live as long as the app.

## Remote runs

Actions run only from a successfully built release: push a tag starting with `v` (MCP `projects_create_tag`), the build bundles each action with its dependencies, and an invalid action fails the build.

```http
POST https://git.fullstacked.cloud/api/v1/projects/<projectId>/actions/add-todo/run
Authorization: Bearer <fsc_* token>
Content-Type: application/json

{ "tag": "v1.2.0", "params": { "title": "Ship docs" } }
```

`tag` is optional: the newest succeeded release the caller can access is used. The response is `200` even when the run failed or timed out:

```json
{
  "run_id": "3c1f0e2a-...",
  "action": "add-todo",
  "tag": "v1.2.0",
  "status": "succeeded",
  "exit_code": 0,
  "error": null,
  "duration_ms": 1840,
  "created_at": "2026-10-23T14:02:11.000Z",
  "output": { "id": 42, "title": "Ship docs", "priority": "medium" }
}
```

| Status | When |
| :--- | :--- |
| `succeeded` | The handler returned; `output` is its JSON result. |
| `failed` | `setup` or the handler threw (`error` is its message; an error with a 5-character `code` such as a PostgreSQL SQLSTATE becomes `SQLSTATE <code>: <message>`), a non-zero exit, or no valid result. |
| `timeout` | The run exceeded 30 s (`ACTION_TIMEOUT_MS`) and was killed. |

Other endpoints (same host, `Authorization: Bearer`):

| Endpoint | Returns |
| :--- | :--- |
| `GET /api/v1/projects/:id/actions?tag=` | `{ tag, actions: [{ name, title, description, file, bundle, parameters }] }` of a release (MCP `projects_list_actions`). |
| `GET /api/v1/projects/:id/actions/runs?tag=&action=&status=&limit=` | `{ runs: [{ run_id, action, tag, caller, caller_name, status, exit_code, error, duration_ms, created_at }] }`, newest first (`limit` 1 to 200, default 50). Users without read access see only their own runs. |
| `GET /api/v1/projects/:id/actions/runs/:runId` | `{ run }`: the same fields plus `output`, `logs` (last 256 KiB of stdout and stderr) and `finished_at`. |

Errors: `400 INVALID_PARAMS` params rejected by the schema, `403` tag not accessible, `404` unknown action, tag or release without a successful build, `502` environment resolution failed, `503` sandbox unavailable.

## Sandbox environment

Each run is a fresh container running the bundled action with `fullstacked --file` (runtime 1.0.0-alpha.1844). The code runs under Node: `process` is Node's own, there is no `document`, `fullstacked/auth` (which opens a window) cannot be used, and a relative `fetch("/...")` fails. Keep UI code out of the modules an action imports.

| Limit | Value |
| :--- | :--- |
| Timeout | 30 s by default |
| Memory | 512 MiB, no swap |
| CPU / processes | 1 vCPU, 256 processes |
| Network | Internet allowed (including tunnels.fullstacked.cloud, so managed data and Edge tunnels work through `fullstacked/tunnel`); private networks and the host are blocked |
| Packages | Bundled at release time; nothing is installed at run time |

`process.env` holds the project environment resolved **for the caller** ([Injected process.env](https://docs.fullstacked.cloud/project-runtime)), so a read-only caller gets read-only database credentials and writes fail. On top of it:

| Variable | Value |
| :--- | :--- |
| `AUTH_TOKEN` | The caller's own `fsc_*` token (calls to `/api/v1/me` return the caller). |
| `ACTION_NAME`, `ACTION_RUN_ID`, `ACTION_PARAMS` | Name, run id, validated params as JSON. |
| `RELEASE_TAG`, `PROJECT_ID` | The release and project being run. |
| `FULLSTACKED_ACTION_RUN` | `"1"`. |
| `ACTION_TEARDOWN_TIMEOUT_MS` | Teardown bound in ms. |

These run variables override custom project variables of the same name. Output written to `console.log` / `console.error` is kept as logs; only the handler's return value is the result. Open pools or sockets cannot keep a run alive: the runner exits after `teardown`.

## Who can run

Anyone with access to the release tag (owner, any project tier, or a release-only grant) can list and run actions; the run executes with that caller's permissions on linked data and tunnels.

## Endpoints

### GET /api/v1/projects/:id/actions

Actions compiled in a release (?tag=, default the newest release the caller can access).

- **Service**: `git` (`https://git.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/projects/:id/actions/:name/run

Runs a release's action with the caller's own token and data wiring; answers 200 with the run result even when the run failed or timed out.

- **Service**: `git` (`https://git.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

Request:

```json
{
  "tag": "v1.0.0",
  "params": {
    "customerId": "cus_123"
  }
}
```

curl:

```bash
curl -sS -X POST -H "Authorization: Bearer $AUTH_TOKEN" -H "Content-Type: application/json" -d '{"tag":"v1.0.0","params":{"customerId":"cus_123"}}' "https://git.fullstacked.cloud/api/v1/projects/<projectId>/actions/sync-orders/run"
```

### GET /api/v1/projects/:id/actions/runs

Past action runs, filtered by ?tag=, ?action=, ?status= and ?limit=.

- **Service**: `git` (`https://git.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### GET /api/v1/projects/:id/actions/runs/:runId

One action run.

- **Service**: `git` (`https://git.fullstacked.cloud`)
- **Auth**: Bearer fsc_*
