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 ({ effects }) => ({
    name: i18n('Set Admin Password'),
    description: i18n('Generate a new random password for the admin account. Replaces any existing password.'),
    // A no-input action runs on click unless `warning` is set; then the UI confirms first.
    warning: (await storeJson.read(s => s.adminPassword).const(effects)) ? i18n('Replaces the current admin password.') : null,
    allowedStatuses: 'any', // 'any', 'only-running', 'only-stopped'
    group: null,
    visibility: 'enabled', // 'enabled', 'disabled', 'hidden'
    access: 'user', // 'public' | 'dependent' | 'user' — who may invoke directly via effects.action.run (default 'user')
  }),

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

Controlling Access

The optional access field on the metadata controls who may invoke the action directly, with sdk.action.run (see Running Another Service’s Action):

  • 'user' (default) — only the user; another service must request it through a task (effects.action.createTask(...)).
  • 'dependent' — only services that declare this package as a current dependency.
  • 'public' — any installed package.

access is independent of visibility (whether the action is shown/enabled) and allowedStatuses (which run states permit it); a direct cross-package run is rejected if access denies the caller.

Knowing Who Is Calling

access decides whether another service may run the action. caller tells the action which service is running it, so it can decide what that service is allowed to touch. The run handler, the prefill function, and an input spec written as a function each receive it:

  • a package id — the service that reached the action through effects.action.run or effects.action.getInput. A service that runs one of its own actions that way sees its own id.
  • null — the user ran it, or StartOS is reading the form to evaluate a task.

StartOS supplies caller; the calling service cannot set or forge it. Take identity from caller, never from the input. An action that lets a service register something against “its own” host must not accept a package id as a field — any service allowed to call it could name another:

export const registerEndpoint = sdk.Action.withInput(
  'register-endpoint',
  async () => ({
    name: i18n('Register Endpoint'),
    description: i18n('Register a host of the calling service'),
    warning: null,
    allowedStatuses: 'any',
    group: null,
    visibility: 'hidden',
    access: 'dependent',
  }),
  InputSpec.of({ hostId: Value.text({ name: 'Host', required: true, default: null }) }),
  async () => null,
  async ({ effects, input, caller }) => {
    if (caller === null) throw new Error('Only a service can register an endpoint')
    // `caller` is who asked; `input.hostId` is which of its hosts.
    await register(effects, { packageId: caller, hostId: input.hostId })
  },
)

Running Another Service’s Action

sdk.action.run runs one of this service’s own actions, or another service’s that its access admits. An action that takes input is run the way the user runs it: its form is opened first, and the input is checked against that form. So input is a function. It receives the opened form — its spec, and the value the action’s prefill function supplied — and returns the input to submit. prefill seeds the form, including the values its dynamic fields are computed from.

await sdk.action.run({
  effects,
  packageId: 'directory',
  actionId: 'register-endpoint',
  input: ({ value }) => ({ ...value, hostId: 'api' }),
})

An action without input takes no input, and runs without a form. Calling the effects directly works the same way: the target keys the form effects.action.getInput opens by the calling procedure’s event id, so the effects.action.run that answers it must come from the same procedure, one form at a time.

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.

message is prose shown under the title. It is rendered as Markdown — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Guidance and next steps go here.

return {
  version: '1',
  title: 'Diagnostics',
  message: `### Checks

- database: **ok**
- search index: **rebuilding**

Restart the service once the rebuild finishes.`,
  result: null,
}

result holds the values the user acts on — copies, scans, or saves. It takes one of three types:

typeRenders asTakes
singlea one-line fieldvalue, plus optional copyable, qr, masked, launchable
multilinea read-only monospace box that keeps its line breaksvalue, plus optional copyable, qr, masked, filename
groupan accordion of named members, each of which is any of these threevalue, the array of members

A newline in a single value is not rendered — the browser strips it from the field — so anything with its own line structure is a multiline value. filename is what separates “here is some text” from “here is a file”: set it and the value is also offered as a download under that name; omit it for no download button.

Single Value

result: {
  type: 'single',
  value: 'abc123',
  masked: true,
  copyable: true,
  qr: false,
}

copyable, qr, masked and launchable are all optional and default to false. launchable puts an open-in-new-tab button beside the value, so a result that hands the user a link — an authorization URL, an admin panel — can be clicked straight through:

result: {
  type: 'single',
  value: 'https://btcpay.example.com/api-keys/authorize?permissions=btcpay.store.cancreateinvoice',
  copyable: true,
  launchable: true,
}

The value must be an http(s) URL for the button to go anywhere.

Multi-line Value

result: {
  type: 'multiline',
  value: report,
  copyable: true,
  filename: 'vikunja-doctor.txt',
}

Group of Values

A member carries a name, and an optional description, on top of whatever its own type takes:

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 },
    { type: 'multiline', name: 'Device Config', description: null, value: config, masked: true, copyable: true, qr: true, filename: 'start-tunnel.conf' },
  ],
}

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({ charset, len }), 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/taskDisableRegistrations.ts
export const taskDisableRegistrations = 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 ? i18n('New accounts can no longer be created. Existing accounts are unaffected.') : 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.

Validating Input

A text field’s patterns are checked before your handler runs, on every path into the action — the form, start-cli package action run, and a direct RPC call alike. A value that fails one is rejected with that pattern’s description, so the caller sees the same message wherever they came from.

sessionTimeout: Value.text({
  name: i18n('Session Timeout'),
  required: false,
  default: null,
  patterns: [
    {
      regex: '^([0-9]+(s|m|h))+$',
      description: i18n('Must be a number followed by s, m, or h'),
    },
  ],
}),

Two details worth knowing, both inherited from how the form has always behaved:

  • A pattern is anchored. [a-z]+ matches the whole value, not a substring — write it as though ^ and $ were there, because they are added if you leave them off.
  • An empty value skips its patterns, and is left to required. An optional field the user leaves blank is not made invalid by a pattern it could never satisfy.

Anything a pattern can’t express — a cross-field rule, a value that has to exist on disk — still belongs in the handler, where a throw surfaces to the caller the same way.

Generating Values in a Form

When a form field holds a secret, don’t generate it in package code. Value.text accepts a RandomString spec — { charset, len } — in two places, and StartOS does the generating:

password: Value.text({
  name: i18n('Password'),
  description: i18n('Leave as generated, or choose your own'),
  required: true,
  masked: true,
  // Pre-fill the field with a fresh random value each time the form opens
  default: { charset: 'a-z,A-Z,0-9', len: 32 },
  // …and/or render a "generate" button that refills it on demand
  generate: { charset: 'a-z,A-Z,0-9', len: 32 },
}),

default also takes a plain string when you want a fixed literal. The same RandomString shape is what utils.getDefaultString resolves in a withoutInput handler — between the two, package code never needs its own random-string generator.

Conventions

Confirm Before a No-Input Action Changes State

A no-input action runs on click, so one that changes state sets warning, and the UI asks the user to confirm first. That holds for a reversible toggle too: the point is to prevent unexpected execution, not only damage. The warning names what changes — what is replaced, stops working, restarts or becomes exposed — never just “Are you sure?”. An action with input needs no warning, since the form is the confirmation, and an action that only reports (credentials, node info) needs neither.

A create-or-update action, such as setting an admin password or token, warns only when it replaces an existing value and sets warning: null on first creation, as Action Without Input shows.

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

That includes what a handler throws. An error out of an action handler is not a log line the user never sees — StartOS catches it and renders the message as the alert that tells them the action failed, so it is the only feedback they get and it needs a dictionary entry like any other:

if (!apiKey) {
  throw new Error(i18n('An API key is required. Create one under Settings → API Keys.'))
}

Wrapping it works because setupI18n resolves eagerly against the container’s locale and hands back a finished string; StartOS renders an unrecognized string verbatim, so a translated message reaches the user in their language and an untranslated one leaks English into the alert.

Errors thrown outside an action are a different matter. A throw from setupMain, setupInit, or a migration reaches the user as a Service Launch Error — a crash report shown next to Rebuild and Uninstall buttons, not copy anyone composed. Those are diagnostics: leave them as plain strings, and keep them specific enough to debug from.

Don’t as const What the SDK Already Types

Action metadata and results are contextually typed by the SDK’s own signatures — version: '1' is declared as the literal '1', and visibility, allowedStatuses, access, and type are unions, not string. Write the literal and stop:

// GOOD — the SDK narrows these for you
return { version: '1', title: i18n('Login Credentials'), ... }

// NOISE — asserts something the compiler already knows
return { version: '1' as const, ... }

tsc passes either way, which is why the assertions spread by copy-paste. They aren’t load-bearing anywhere in an action file; drop them when you see them. (Distinct from an as cast, which claims the compiler is wrong — reach for that only when it actually is.)

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.looseObject({
  adminPassword: z.string().optional(),
  secretKey: z.string().optional(),
  smtp: smtpShape,
})

export const storeJson = FileHelper.json({ base: sdk.volumes.startos, 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/seedFiles.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 }
            }
          }
        }
      }
    }