> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polycore.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Author actions

> Define typed, secret-blind operations that execute on the customer-hosted runner.

An action is a named TypeScript operation loaded by the customer-hosted runner.
It turns a stable production workflow into a typed catalog capability without
giving the caller its implementation credentials.

Actions live in your runner repository and are authored against the runner SDK
(`@polycore/runner`).

## Action boundary

<Columns cols={2}>
  <Card title="The caller supplies" icon="log-in">
    Schema-valid input through Slack, MCP, a dashboard, or another configured
    interface.
  </Card>

  <Card title="The runner supplies" icon="key-round">
    The action's declared local secrets and a structured logger. Secret values
    do not enter the input schema.
  </Card>
</Columns>

The runner is already bound to one project and environment, so an action does
not select a credential bundle from a caller-provided environment argument.

## Directory-derived identity

Actions are discovered recursively:

<Tree>
  <Tree.Folder name="actions" defaultOpen>
    <Tree.Folder name="billing" defaultOpen>
      <Tree.Folder name="set-plan" defaultOpen>
        <Tree.File name="action.ts" />
      </Tree.Folder>
    </Tree.Folder>
  </Tree.Folder>
</Tree>

The loader derives the action id from the folder path. Do not add an `id` field
to the definition.

## Complete example

```typescript actions/billing/set-plan/action.ts theme={"system"}
import { defineAction, z } from "@polycore/runner";

const input = z.object({
  accountId: z.string().min(1),
  plan: z.enum(["starter", "growth", "enterprise"]),
});

const output = z.object({
  accountId: z.string(),
  previousPlan: z.string(),
  currentPlan: z.string(),
});

export default defineAction({
  title: "Set account plan",
  description:
    "Move one account to a billing plan after validating it in the billing service.",
  tags: ["billing", "account"],
  hazard: "write",
  input,
  output,
  secrets: {
    BILLING_API_TOKEN: "Token scoped to account plan updates.",
  },
  async run({ input: args, secrets, log }) {
    log.info(`Updating plan for account ${args.accountId}.`);

    const response = await fetch(
      `https://billing.internal/v1/accounts/${encodeURIComponent(args.accountId)}/plan`,
      {
        method: "PUT",
        headers: {
          authorization: `Bearer ${secrets["BILLING_API_TOKEN"]}`,
          "content-type": "application/json",
        },
        body: JSON.stringify({ plan: args.plan }),
      },
    );

    if (!response.ok) {
      throw new Error(`Billing service returned HTTP ${response.status}.`);
    }

    const data: unknown = await response.json();
    const result = output.parse(data);
    log.success(`Updated account ${args.accountId}.`);
    return result;
  },
});
```

<Warning>
  Never log `secrets`, authorization headers, connection strings, or raw
  credential errors. Logs and structured outputs can cross into the
  control-plane request history.
</Warning>

## Definition fields

<ParamField path="title" type="string" required>
  Human-readable operation name used in catalog and approval surfaces.
</ParamField>

<ParamField path="description" type="string" required>
  Tell an agent when to use the action, what it changes, and important
  preconditions. Keep it customer-specific only in the customer's integration
  repository.
</ParamField>

<ParamField path="tags" type="string[]">
  Optional organization metadata for the catalog.
</ParamField>

<ParamField path="hazard" type="&#x22;read&#x22; | &#x22;write&#x22;" default="&#x22;write&#x22;">
  What the action is capable of. With no `policy` it is the whole decision:
  reads dispatch and writes wait for a human. The default is `write`, so an
  omitted classification fails safe.
</ParamField>

<ParamField path="input" type="Zod schema" required>
  Defines and validates all caller-controlled arguments before execution.
</ParamField>

<ParamField path="output" type="Zod schema" required>
  Defines the structured result advertised to callers. The action implementation
  must validate untrusted downstream data against it before returning, as the
  example does with `output.parse(data)`.
</ParamField>

<ParamField path="secrets" type="record<string, string>">
  Maps every local secret key the action needs to a human-readable description.
</ParamField>

<ParamField path="run" type="function" required>
  Receives `{ input, secrets, log }` and returns the declared output, either
  directly or as a promise.
</ParamField>

<ParamField path="policy" type="function">
  Decides whether this invocation dispatches, waits for an admin, or is refused,
  overriding the hazard default. Receives `{ input, environment, via }` and
  returns `{ decision: "allow" }`, `{ decision: "admin_approval", because }`, or
  `{ decision: "never", because }`.

  ```typescript theme={"system"}
  policy: ({ input, environment }) =>
    environment === "production" && input.durationDays > 30
      ? {
          decision: "admin_approval",
          because: `A ${input.durationDays}-day grant is a commercial decision.`,
        }
      : { decision: "allow" },
  ```

  It must be a pure function of its context: no I/O, no clock, no counters.
  `because` reaches the approver and the audit record. See [approvals and
  audit](/concepts/approvals-and-audit).
</ParamField>

## Import rules

Import `defineAction`, `z`, and action types from the runner SDK:

```typescript theme={"system"}
import { defineAction, z } from "@polycore/runner";
```

The runner re-exports its pinned Zod instance. Installing and importing another
Zod copy can break the JSON Schema conversion used to advertise tool contracts.

Actions are headless Node.js modules. They must not import React, dashboard
components, browser APIs, or `@polycore/ui`.

## Hazard selection

<Tabs>
  <Tab title="read">
    Use only for side-effect-free operations: the implementation and credential
    must not mutate state. Marking an action `read` states that the operation
    carries no side effects.
  </Tab>

  <Tab title="write">
    Use for any operation that creates, changes, deletes, sends, triggers, or
    otherwise causes an external side effect. This is the default.
  </Tab>
</Tabs>

If an operation appears side-effect-free but triggers metering, sends
notifications, advances a cursor, or changes a remote cache, classify it as
`write`.

## Secret resolution

An action declares names, not values:

```typescript theme={"system"}
secrets: {
  BILLING_API_TOKEN: "Token scoped to account plan updates.",
}
```

At invocation time, the runner:

1. Confirms that each declared key has a local value.
2. Builds the `secrets` object for that action.
3. Executes on the runner host.
4. Never adds the values to capability metadata or the caller's input.

<Tip>
  Give different actions different credentials when their authority differs. A
  read action and a write action should not share a broad token merely because
  they call the same service.
</Tip>

## Input design

Good action inputs are narrow, explicit, and reviewable.

<Columns cols={2}>
  <Card title="Prefer stable identifiers" icon="fingerprint">
    Accept `accountId` rather than an unbounded search phrase when the action
    targets one account.
  </Card>

  <Card title="Encode limits" icon="ruler">
    Use enums, numeric bounds, URL validation, and discriminated unions so
    invalid authority cannot be requested.
  </Card>

  <Card title="Avoid secret arguments" icon="key-round">
    Secrets belong in the action declaration and runner environment, not in
    caller input.
  </Card>

  <Card title="Keep approval legible" icon="scan-text">
    An approver should be able to understand the requested change from the
    action title and validated input.
  </Card>
</Columns>

## Output design

Return enough information to confirm the effect without leaking the downstream
credential or a large raw response.

Recommended output fields include:

* The stable id of the affected resource.
* The previous and current state relevant to the operation.
* A downstream operation id when it is safe and useful.
* An explicit status for no-op or already-complete behavior.

Avoid returning full customer records when the caller needs only a confirmation.

## Failure behavior

Throw an error when the action cannot establish the declared result. Do not
convert permission failures, timeouts, validation errors, or downstream
rejections into successful-looking output.

Use the logger for concise progress:

* `log.info` for meaningful start or stage information.
* `log.warn` for recoverable conditions the caller should know.
* `log.error` before a failure only when it adds context.
* `log.success` for a completed effect.

Keep successful actions quiet enough that the structured output remains the
primary result.

## Review checklist

<Check>
  The action imports only from the runner SDK (`@polycore/runner`) for its
  Polycore and Zod API.
</Check>

<Check>
  The folder path provides the intended catalog id and the definition has no
  `id` field.
</Check>

<Check>
  Input and output schemas are narrow, and the hazard matches every side effect.
</Check>

<Check>
  Declared secrets are the minimum authority required and are not logged or
  returned.
</Check>

<Check>Downstream failures remain failures and outputs are bounded.</Check>

<Check>
  Tests cover success, validation, authorization failure, and relevant no-op or
  idempotency behavior before deployment.
</Check>
