LaufwerkLaufwerk
API reference

Human

Confirmation, text, selection and notification options, responses, durable waiting and examples.

Human records a request in the run. Questions suspend the workflow until a valid answer arrives; notifications record information and continue. Import Human, HumanError and HumanChoiceOption from @laufwerk/sdk.

Common properties

confirm, text, select and notify accept these properties:

PropertyTypeRequiredMeaning
keystringYesNonempty stable identity, unique across this run's Human requests
titlestringYesNonempty request title shown to the person
descriptionstringNoAdditional context needed to answer or understand the notice
view{ component: string; data: HumanViewData }NoOptional custom display, usually produced by HumanView.make(...).encode(data)

Titles and descriptions are request content, not agent prompts. There are no timeout, defaultValue, placeholder, required, callback, recipient or delivery-channel properties on these methods.

Methods and results

MethodAdditional propertiesSuccessful valueWaits for an answer?
Human.confirm(options)NonebooleanYes
Human.text(options)NonestringYes
Human.select(options)Required options: readonly HumanChoiceOption[]Selected option's valueYes
Human.notify(options)NonevoidNo

All return Effects with HumanError as their failure type. confirm returning false is a successful response, not an exception. Your workflow decides what a negative answer means. text has no custom schema option: validate the returned string in workflow code if your process needs a particular format.

HumanChoiceOption<Value extends string = string>

PropertyTypeRequiredMeaning
valueValue (a string)YesMachine-readable result, such as "revise"
labelstringYesVisible option label
descriptionstringNoAdditional explanation of this option

The options array must contain at least one entry; values and labels must be nonempty. Use distinct values and meaningful labels. A selection response must match an offered value. With literal options, TypeScript infers the returned union, for example "approve" | "revise". Selection is single-choice, not multi-select.

Example: collect feedback and branch

human-example.ts
import { Effect } from "effect";
import { Human } from "@laufwerk/sdk";

export const review = () => Effect.gen(function* () {
  const decision = yield* Human.select({
    key: "review-decision",
    title: "How should this draft proceed?",
    description: "The draft and checks are ready for your review.",
    options: [
      { value: "approve", label: "Approve", description: "Accept this version." },
      { value: "revise", label: "Request changes" },
    ],
  });
  if (decision === "revise") {
    const feedback = yield* Human.text({ key: "feedback", title: "What should change?" });
    return { status: "revision-requested" as const, feedback };
  }
  const confirmed = yield* Human.confirm({
    key: "confirm-acceptance", title: "Record this draft as accepted?",
  });
  if (!confirmed) return { status: "declined" as const };
  yield* Human.notify({ key: "accepted-notice", title: "Draft accepted" });
  return { status: "accepted" as const };
});

Human.request: typed custom responses (alpha.13)

Use a shared form contract when a decision needs multiple fields. Unlike text, request validates the response against that contract before accepting it.

PropertyTypeRequiredMeaning
keystringYesStable nonempty request identity
titlestringYesNonempty visible title
descriptionstringNoReview instructions
viewHumanForm<Id, A, I, R, RI>YesForm created with HumanView.make
dataAYesDecoded data matching the form's data schema

Returns an Effect yielding the form's decoded response R, failing with HumanError, requiring the same runtime/engine services as other Human questions.

HumanView.make and JSON boundaries

Import HumanView, HumanView (type), and HumanForm from @laufwerk/sdk/human-view. HumanView.make(id, { data, response? }) is synchronous: id is the nonempty component identifier; data and optional response are Effect schemas requiring no services. The returned contract has id, data, encode(value): Effect<HumanViewRequest, HumanError> and, for forms, response. Without response, it is display-only and cannot be passed to Human.request.

Data codecs must be synchronous and deterministic. Encoded data must contain only JSON values: finite numbers, strings, booleans, null, dense arrays and plain objects. No undefined, dates, cycles, functions or getters. The limit is 256 KiB of serialized UTF-8 JSON and 64 nesting levels. Excess properties are rejected. HumanViewData and HumanViewRequest are exported by @laufwerk/protocol.

Response schemas must be JSON-native. Transformations, declarations and suspended schemas are rejected; refinements require equivalent JSON Schema annotations. Both the persisted schema and the SDK decoder validate the submitted value.

Complete example: contract, workflow and Studio form

Use these four files in an initialized alpha.13 consumer. Add the browser packages to the consumer from your repository root:

bun add --cwd laufwerk --exact @laufwerk/studio@0.0.1-alpha.13 react@19.2.8
bun add --cwd laufwerk --dev --exact @types/react@19.2.18

In laufwerk/tsconfig.json, add "jsx": "react-jsx" to compilerOptions and include "**/*.tsx" alongside "**/*.ts". Preserve the other generated settings. The default initializer does not configure custom React views.

Save the shared contract as laufwerk/studio/draft-review.ts. It imports no server modules, so both the workflow and browser can use it.

studio/draft-review.ts
import { Schema } from "effect";
import { HumanView } from "@laufwerk/sdk/human-view";

export const DraftReview = HumanView.make("draft-review", {
  data: Schema.Struct({ draft: Schema.String }),
  response: Schema.Struct({
    decision: Schema.Literal("accept", "revise"),
    feedback: Schema.String,
  }),
});

Save the following workflow as laufwerk/workflows/review-draft/workflow.ts. Start it with input such as {"requestId":"draft-1","draft":"A draft to review"}.

workflows/review-draft/workflow.ts
import { Workflow } from "@effect/workflow";
import { Effect, Schema } from "effect";
import { Human } from "@laufwerk/sdk";
import { DraftReview } from "../../studio/draft-review";

export const workflow = Workflow.make({
  name: "review-draft",
  payload: { requestId: Schema.String, draft: Schema.String },
  success: DraftReview.response, error: Schema.String,
  idempotencyKey: input => input.requestId,
});
export const layer = workflow.toLayer(input => Human.request({
  key: "draft-review-1", title: "Review the draft",
  view: DraftReview, data: { draft: input.draft },
}).pipe(Effect.mapError(String)));

Save the component as laufwerk/studio/draft-review-form.tsx. It awaits the submission, prevents overlapping submissions and displays validation/server errors.

studio/draft-review-form.tsx
import { useState } from "react";
import type { HumanViewProps } from "@laufwerk/studio/react";
import { DraftReview } from "./draft-review";

type Props = HumanViewProps<
  typeof DraftReview.data.Type,
  typeof DraftReview.response.Type
>;
export default function DraftReviewForm({ data, submit, submitting, disabled }: Props) {
  const [feedback, setFeedback] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string>();
  const blocked = disabled || submitting || pending;

  async function respond(decision: "accept" | "revise") {
    if (blocked) return;
    setPending(true);
    setError(undefined);
    try {
      await submit({ decision, feedback });
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : "Could not save your response.");
    } finally {
      setPending(false);
    }
  }

  return <section aria-label="Draft review">
    <p style={{ whiteSpace: "pre-wrap" }}>{data.draft}</p>
    <label>
      Feedback
      <textarea value={feedback} disabled={blocked}
        onChange={event => setFeedback(event.target.value)} />
    </label>
    <div>
      <button type="button" disabled={blocked} onClick={() => void respond("accept")}>Accept</button>
      <button type="button" disabled={blocked} onClick={() => void respond("revise")}>Request changes</button>
    </div>
    {error && <p role="alert">{error}</p>}
  </section>;
}

Register it in laufwerk/studio/views.ts. If you already have a registry, add the entry to its default array. Each contract ID identifies one registered view.

studio/views.ts
import { registerView } from "@laufwerk/studio/react";
import { DraftReview } from "./draft-review";

export default [
  registerView(DraftReview, () => import("./draft-review-form")),
];

Run bun run --cwd laufwerk check, then restart Studio to build the registered view. Start review-draft and open its pending interaction. Acceptance records the response; this example does not publish the draft.

Studio registration contract

registerView(contract, load) returns RegisteredView with id and a lazy React Component. load returns a promise of a module whose default export accepts HumanViewProps<A, R>: data: A, submit(value: R): Promise<void>, submitting: boolean, and disabled: boolean. The registry decodes data and encodes responses using the shared contract. Display-only contracts disable submission. Rebuild/restart Studio after changing its consumer view registry.

A CLI response remains available even when the renderer is unavailable; server validation still applies. Use respond RUN_ID INTERACTION_ID --response '<json>'.

Custom response envelope for the example:

{ "kind": "custom", "value": { "decision": "revise", "feedback": "Add sources." } }

Answering a request

Use Studio or the CLI's respond command. These JSON shapes are the response envelopes, not arguments to the Human methods:

{ "kind": "confirmation", "value": true }
{ "kind": "text", "value": "Please add evidence for the second claim." }
{ "kind": "single-choice", "value": "revise" }

Wrong response kinds and unoffered choices are rejected. Notifications do not accept responses. notify does not itself send email, Slack or a push notification.

Replay and approvals

The request and answer survive workflow suspension. Replaying the same request identity reuses its recorded state; use a distinct deterministic key for a later review round. Native agent tool approvals are separate interactions, configured through agent permissions and answered with CLI approve, not a hidden Human.confirm call.

On this page