Skip to content

Actions ​

A workflow form's submit button advances the flow. Actions are the other buttons next to it — resend code, save draft, use backup method, forgot password. Each one posts back to the same step with an action name, optionally with form data.

The two action kinds ​

KindAnnotationSends data?Server reads via
Plain action@ui.form.action 'name'No@WfAction(Form) param (step skipped unless @WfInput({ pass: true }))
Action with data@wf.action.withData 'name'Yes (deep-partial validated)@WfAction(Form) param + @WfInput() data

Both render as buttons in the rendered form's action row. Both let the step keep the user on the same screen, re-render the same form, finish the flow, or do something arbitrary.

Declaring actions on the form ​

Actions are declared as fields on the form type, with the action annotation:

atscript
@meta.label 'Verify Identity'
@ui.form.submit.text 'Verify Code'
export interface MfaPincodeForm {
    @meta.label 'Verification Code'
    @ui.form.placeholder '000000'
    @meta.required 'Code is required'
    @expect.minLength 6, '6 digits expected'
    @expect.maxLength 6, '6 digits expected'
    code: string

    // Plain action — no form data.
    @ui.form.action 'resend'
    @meta.label 'Resend Code'
    _resend?: boolean

    // Action with data — sends current form fields with deep-partial validation.
    @wf.action.withData 'saveDraft'
    @meta.label 'Save Draft'
    _saveDraft?: boolean
}

The field itself is a placeholder — the runtime never stores a value on it. It only carries the annotation and the label. Use a leading underscore name to make the intent clear, and make it optional so it doesn't fail required-field validation.

The form renderer reads these annotations and emits one button per action next to the submit button.

Reading the action on the server ​

Two ways, mirroring how you read input.

Decorator: @WfAction(Form) ​

ts
import { WfInput, WfAction, useAtscriptWf } from "@atscript/moost-wf";
import { MfaPincodeForm } from "../forms/MfaPincodeForm.as";

@Step("login-verify-otp")
async verifyOtp(
  @WorkflowParam("context") ctx: LoginCtx,
  @WfAction(MfaPincodeForm) action: string | undefined,
  @WfInput({ pass: true }) input?: MfaPincodeForm,
) {
  const wf = useAtscriptWf(MfaPincodeForm);

  if (action === "resend") {
    ctx.otpCode = String(Math.floor(100000 + Math.random() * 900000));
    await sendOtpEmail(ctx.email!, ctx.otpCode);
    // Re-pause the same form (no errors) so the user can re-enter the code.
    throw wf.requireInput();
  }

  if (action === "saveDraft") {
    // input here is partial-validated form data from @wf.action.withData.
    await drafts.save(ctx.userId!, input ?? {});
    throw wf.requireInput();
  }

  // No action → normal submit path; input is fully validated.
  if (input!.code !== ctx.otpCode) {
    throw wf.requireInput({ errors: { code: "Invalid code" } });
  }
  return;
}

@WfAction(Form) returns the action name string, or undefined for a normal submit. The handler dispatches with a regular switch / if chain. The form-type argument is required — the decorator validates the incoming action against the form's @ui.form.action / @wf.action.withData whitelist and throws StepRetriableError for unknown actions before the step body runs.

@WfInput({ pass: true }) opts this step into handling no-data actions (@ui.form.action) without forcing input — the parameter resolves to undefined when a no-data action fires. Without pass: true, the decorator throws on no-data actions.

Use ?: syntax (input?: Form), not a union (input: Form | undefined) — the union breaks atscript metadata reflection (TS emits Object instead of the AsType for union-typed parameters). @WfInput({ pass: true }) composes @Optional() internally so global validator pipes skip the undefined value.

Composable: useAtscriptWf() ​

For full explicit control (no decorator policy matrix), call the composable directly:

ts
import { useAtscriptWf } from "@atscript/moost-wf";

@Step("login-verify-otp")
async verifyOtp(@WorkflowParam("context") ctx: LoginCtx) {
  const wf = useAtscriptWf(MfaPincodeForm);
  const action = wf.resolveAction();

  if (action === "resend") {
    ctx.otpCode = String(Math.floor(100000 + Math.random() * 900000));
    await sendOtpEmail(ctx.email!, ctx.otpCode);
    throw wf.requireInput();
  }

  const input = wf.resolveInput({ partial: action === "saveDraft" ? "deep" : undefined });
  if (action === "saveDraft") {
    await drafts.save(ctx.userId!, input);
    throw wf.requireInput();
  }
  if (input.code !== ctx.otpCode) {
    throw wf.requireInput({ errors: { code: "Invalid code" } });
  }
}

resolveAction() throws if the action isn't declared on the form schema; resolveInput() throws if input is missing or invalid. Both return a StepRetriableError the workflow engine catches natively.

How validation differs per kind ​

@WfInput() inspects the action before validating:

  • No action (normal submit): full validation; missing required fields cause re-pause with errors.
  • @ui.form.action (plain): the step is skipped unless declared with @WfInput({ pass: true }). With pass: true, the parameter resolves to undefined and the handler runs. Useful for resend code, forgot password, cancel.
  • @wf.action.withData: deep-partial validation. The framework only checks the fields actually present in the payload — missing fields are not "required-failing". Useful for save draft flows where the user might be halfway through.

If the action name is not declared on the form, the framework re-pauses with a __form error "Action "<name>" is not supported".

Calling actions from the client ​

The client side mirrors the server: the rendered form has buttons labeled with each action; clicking one fires the action via the underlying composable.

From the default <AsWfForm> ​

Nothing extra to wire — the component reads the form type's annotations, classifies each action as plain vs with-data (based on which annotation it carries), and POSTs the right body.

From useWfForm() directly ​

ts
import { useWfForm } from "@atscript/vue-wf";

const wf = useWfForm({ path: "/wf", name: "auth/login" });

// Plain action — no body besides token + action name.
wf.action("resend");

// Action with data — current form data, validated deep-partial server-side.
wf.actionWithData("saveDraft", currentFormData);

Bodies:

http
POST /wf
{ "wfs": "<token>", "input": { "action": "resend" } }
http
POST /wf
{ "wfs": "<token>", "input": { "action": "saveDraft", "formData": { "code": "123" } } }

action and formData ride inside the workflow's input envelope. The wf engine wipes body.input after each step, so the action is cleared for free between in-request step transitions — no cleanup interceptor needed.

Common patterns ​

Resend a code ​

ts
if (action === "resend") {
  ctx.otpCode = String(Math.floor(100000 + Math.random() * 900000));
  await sendOtp(ctx.email!, ctx.otpCode);
  throw useAtscriptWf(MfaPincodeForm).requireInput(); // re-render same form, no errors
}

Forgot password (jump to a different flow) ​

ts
if (action === "forgotPassword") {
  // Finish the current flow with a "redirect" payload — let the client
  // mount a different workflow.
  finishWf({ data: { redirect: "/recover" } });
  return;
}

Switch verification method ​

ts
if (action === "useBackup") {
  ctx.mfaMethod = "backup-codes";
  return; // advance — the schema's condition routes us to a different step
}

The action handler can do anything a normal step can — call finishWf() / abortWf(), mutate context to flip a branch condition, or re-pause the same form. The flow stays a flow.

Where to go next ​

  • Form Input & Validation — @WfInput() and useAtscriptWf().requireInput() patterns reused above.
  • Context Passing — how the form renderer reads context (e.g. to label the resend button with the masked email).
  • Forms / Annotations — the full annotation reference, including @ui.form.action.

Released under the MIT License.