Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Actions

Actions are user-triggered operations that appear in the StartOS UI for your service. They can display information, accept user input, modify configuration, and more.

Action Without Input

The simplest action type does its work and returns a result to display. The canonical “set admin password” action generates a random password, writes it to the store, and returns the new credential — the same action serves first-set (surfaced by a critical task on install) and later rotation:

import { utils } from "@start9labs/start-sdk";
import { i18n } from "../i18n";
import { sdk } from "../sdk";
import { storeJson } from "../fileModels/store.json";

export const setAdminPassword = sdk.Action.withoutInput(
  // ID
  "set-admin-password",

  // Metadata
  async () => ({
    name: i18n("Set Admin Password"),
    description: i18n(
      "Generate a new random password for the admin account. Replaces any existing password.",
    ),
    warning: null,
    allowedStatuses: "any", // 'any', 'only-running', 'only-stopped'
    group: null,
    visibility: "enabled", // 'enabled', 'disabled', 'hidden'
  }),

  // Handler
  async ({ effects }) => {
    const adminPassword = utils.getDefaultString({
      charset: "a-z,A-Z,0-9",
      len: 32,
    });
    await storeJson.merge(effects, { adminPassword });

    return {
      version: "1",
      title: i18n("Login Credentials"),
      message: i18n("Use these credentials to sign in."),
      result: {
        type: "group",
        value: [
          {
            type: "single",
            name: i18n("Username"),
            description: null,
            value: "admin",
            masked: false,
            copyable: true,
            qr: false,
          },
          {
            type: "single",
            name: i18n("Password"),
            description: null,
            value: adminPassword,
            masked: true,
            copyable: true,
            qr: false,
          },
        ],
      },
    };
  },
);

The action is paired with a setupOnInit watcher that surfaces a critical task when no password is stored — generation, storage, and display all live in this one handler, so first-set and rotation share a single code path. See Prompt User to Create Admin Credentials.

Registering Actions

All actions must be registered in actions/index.ts:

import { sdk } from "../sdk";
import { setAdminPassword } from "./setAdminPassword";

export const actions = sdk.Actions.of().addAction(setAdminPassword);

Result Types

Actions return structured results that the StartOS UI renders for the user.

Single Value

result: {
  type: 'single',
  name: 'API Key',
  description: null,
  value: 'abc123',
  masked: true,
  copyable: true,
  qr: false,
}

Group of Values

result: {
  type: 'group',
  value: [
    { type: 'single', name: 'Username', description: null, value: 'admin', masked: false, copyable: true, qr: false },
    { type: 'single', name: 'Password', description: null, value: 'secret', masked: true, copyable: true, qr: false },
  ],
}

Tasks

Actions can be surfaced to users as tasks — notifications that prompt them to run a specific action at the right time. See Tasks for details.

Implementation Examples

Auto-Generate Passwords

The standard shape for password actions: the handler generates the password with utils.getDefaultString(), writes it where the service reads it from, and returns it as a masked, copyable result. Server-side generation produces strong passwords and means the same action covers first-set and rotation. The primary example above (setAdminPassword) is the canonical shape — see also the Reset a Password recipe for variants that apply the new password through the upstream service’s CLI or API.

Registration-Gated Services

Some services require that “registrations” or “signups” be enabled for users to create accounts. This creates a security tension: the service must be open for the admin to register, but should be locked down after.

The recommended pattern:

  1. Start with registrations enabled in the initial config.
  2. Create an important task in setupOnInit advising the user to disable registrations after creating their admin account.
  3. Provide a toggle action that reads the current registration state, flips it, and writes back.
// In init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== "install") return;
  await sdk.action.createOwnTask(effects, toggleRegistrations, "important", {
    reason:
      "After creating your admin account, disable registrations to prevent unauthorized signups.",
  });
});

// In actions/toggleRegistrations.ts
import { configToml } from '../fileModels/config.toml'

export const toggleRegistrations = sdk.Action.withoutInput(
  "toggle-registrations",
  async ({ effects }) => {
    const allowed = await configToml
      .read((c) => c.allow_registration)
      .const(effects);
    return {
      name: allowed
        ? i18n("Disable Registrations")
        : i18n("Enable Registrations"),
      description: allowed
        ? i18n(
            "Registrations are currently enabled. Run this action to disable them.",
          )
        : i18n(
            "Registrations are currently disabled. Run this action to enable them.",
          ),
      warning: allowed
        ? null
        : i18n("Anyone with your URL will be able to create an account."),
      allowedStatuses: "any",
      group: null,
      visibility: "enabled",
    };
  },
  async ({ effects }) => {
    const allowed = await configToml
      .read((c) => c.allow_registration)
      .const(effects);
    await configToml.merge(effects, { allow_registration: !allowed });
  },
);

Action With Input

For actions that accept user input, use sdk.Action.withInput() with an InputSpec form, a prefill function, and a handler:

import { sdk } from '../sdk'
import { configFile } from '../fileModels/config'
import { i18n } from '../i18n'
const { InputSpec, Value } = sdk

const inputSpec = InputSpec.of({
  timeout: Value.number({
    name: i18n('Session Timeout'),
    description: i18n('How long before idle sessions expire'),
    required: false,
    default: 30,
    min: 1,
    max: 1440,
    step: 1,
    integer: true,
    units: 'minutes',
  }),
})

export const configure = sdk.Action.withInput(
  'configure',
  {
    name: i18n('Configure'),
    description: i18n('Adjust service settings'),
    warning: null,
    allowedStatuses: 'any',
    group: null,
    visibility: 'enabled',
  },
  inputSpec,
  // Prefill form with current values
  async ({ effects }) => {
    const current = await configFile.read((c) => c.timeout).once()
    return { timeout: current }
  },
  // Handler — write new values
  async ({ effects, input }) => {
    await configFile.merge(effects, { timeout: input.timeout })
  },
)

The five arguments to withInput are: action ID, metadata (static object or async function), input spec, prefill function, and handler.

Conventions

Wrap User-Facing Strings in i18n()

Every string that a user will see — action name, description, warning, reason on tasks, messages on health checks and action results — must be wrapped in i18n(). Raw strings bypass translation and leak English into non-English locales. The existing examples on this page illustrate the pattern: name: i18n('Configure SMTP'), not name: 'Configure SMTP'.

Thrown errors are the exception. throw new Error(...) messages are developer-facing diagnostics that surface in logs and stack traces, not translated UI copy — leave them as plain strings and do not wrap them in i18n().

Mirror File-Model Keys in InputSpec When Appropriate

When an action’s job is “set these fields on this file-model section,” name the InputSpec keys to match the file-model keys exactly — same casing, same spelling. The prefill and handler collapse to one-liners:

// fileModels/config.json uses uppercase snake_case keys under MEMPOOL
const spec = InputSpec.of({
  BLOCKS_SUMMARIES_INDEXING: Value.toggle({ /* ... */ }),
  GOGGLES_INDEXING:          Value.toggle({ /* ... */ }),
  AUDIT:                     Value.toggle({ /* ... */ }),
  CPFP_INDEXING:             Value.toggle({ /* ... */ }),
})

sdk.Action.withInput(
  'configure-indexing',
  { /* metadata */ },
  spec,
  async ({ effects }) => configJson.read((c) => c.MEMPOOL).once(),
  async ({ effects, input }) => configJson.merge(effects, { MEMPOOL: input }),
)

Benefits:

  • Prefill and write collapse to direct pass-throughs — no manual object-literal mapping on either side.
  • If the file model later adds or removes a field the action exposes, TypeScript flags the mismatch instead of silently dropping it.

When not to mirror: if the action transforms values, combines multiple inputs, writes to multiple sections, or writes to a section where the file-model keys aren’t a good user-facing vocabulary. In those cases, use human-readable camelCase input names and do the mapping in the handler.

Note

Action prefills use .once(), not .const(effects). .const() sets up a reactive watcher meant for setupMain — it’s wasted overhead in a prefill, which is a one-shot read at the moment the form opens.

SMTP Configuration

The SDK provides a built-in SMTP input specification for managing email credentials. This supports three modes: disabled, system SMTP (from StartOS settings), or custom SMTP with provider presets (Gmail, Amazon SES, SendGrid, Mailgun, Proton Mail, or custom).

1. Add SMTP to store.json.ts

Use the SDK’s smtpShape zod schema in your store’s shape definition. See File Models for more on file model patterns.

import { FileHelper, smtpShape, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

const shape = z.object({
  adminPassword: z.string().optional(),
  secretKey: z.string().optional(),
  smtp: smtpShape,
});

export const storeJson = FileHelper.json(
  { base: sdk.volumes.main, subpath: "./store.json" },
  shape,
);

2. Create the manageSmtp Action

Use smtpPrefill() in the prefill function to bridge between the stored SmtpSelection type and the input spec’s expected type. These types represent the same data but are structurally different in TypeScript (the store uses a flat union, the input spec uses a distributed discriminated union), so smtpPrefill() handles the conversion.

import { smtpPrefill } from "@start9labs/start-sdk";
import { i18n } from "../i18n";
import { storeJson } from "../fileModels/store.json";
import { sdk } from "../sdk";

const { InputSpec } = sdk;

export const inputSpec = InputSpec.of({
  smtp: sdk.inputSpecConstants.smtpInputSpec,
});

export const manageSmtp = sdk.Action.withInput(
  "manage-smtp",

  async ({ effects }) => ({
    name: i18n("Configure SMTP"),
    description: i18n("Add SMTP credentials for sending emails"),
    warning: null,
    allowedStatuses: "any",
    group: null,
    visibility: "enabled",
  }),

  inputSpec,

  // Pre-fill form with current values
  async ({ effects }) => ({
    smtp: smtpPrefill(await storeJson.read((s) => s.smtp).const(effects)),
  }),

  // Save to store
  async ({ effects, input }) => storeJson.merge(effects, { smtp: input.smtp }),
);

3. Register the Action

import { sdk } from "../sdk";
import { setAdminPassword } from "./setAdminPassword";
import { manageSmtp } from "./manageSmtp";

export const actions = sdk.Actions.of()
  .addAction(setAdminPassword)
  .addAction(manageSmtp);

4. Use SMTP Credentials at Runtime

In your main.ts, resolve the SMTP credentials based on the user’s selection:

import { T } from "@start9labs/start-sdk";

export const main = sdk.setupMain(async ({ effects }) => {
  const store = await storeJson.read().const(effects);

  // Resolve SMTP credentials based on selection
  const smtp = store?.smtp;
  let smtpCredentials: T.SmtpValue | null = null;

  if (smtp?.selection === "system") {
    // Use system-wide SMTP from StartOS settings
    smtpCredentials = await sdk.getSystemSmtp(effects).const();
    if (smtpCredentials && smtp.value.customFrom) {
      smtpCredentials.from = smtp.value.customFrom;
    }
  } else if (smtp?.selection === "custom") {
    // Use custom SMTP credentials from the selected provider
    const { host, from, username, password, security } =
      smtp.value.provider.value;
    smtpCredentials = {
      host,
      port: Number(security.value.port),
      from,
      username,
      password: password ?? null,
      security: security.selection,
    };
  }
  // If smtp.selection === 'disabled', smtpCredentials remains null

  // Pass to config generation
  const config = generateConfig({
    smtp: smtpCredentials,
    // ... other config
  });

  // ...
});

5. Initialize with SMTP Disabled

In init/initializeService.ts, set the default SMTP state alongside any internal-only secrets the service needs. The admin password is set by the setAdminPassword action when the user runs its critical task (see Prompt User to Create Admin Credentials):

await storeJson.merge(effects, {
  secretKey: utils.getDefaultString({ charset: "a-z,A-Z,0-9", len: 64 }),
  smtp: { selection: "disabled", value: {} },
});

T.SmtpValue Type

The resolved SMTP credentials (returned by sdk.getSystemSmtp()) have this structure:

interface SmtpValue {
  host: string;
  port: number;
  from: string;
  username: string;
  password: string | null | undefined;
  security: "starttls" | "tls";
}

SmtpSelection Type

The stored SMTP selection (from smtpShape) has this structure:

type SmtpSelection =
  | { selection: "disabled"; value: Record<string, never> }
  | { selection: "system"; value: { customFrom?: string | null } }
  | {
      selection: "custom";
      value: {
        provider: {
          selection: string; // "gmail", "ses", "sendgrid", etc.
          value: {
            host: string;
            from: string;
            username: string;
            password?: string | null;
            security: {
              selection: "tls" | "starttls";
              value: { port: string };
            };
          };
        };
      };
    };