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:
| Property | Type | Required | Meaning |
|---|---|---|---|
key | string | Yes | Nonempty stable identity, unique across this run's Human requests |
title | string | Yes | Nonempty request title shown to the person |
description | string | No | Additional context needed to answer or understand the notice |
view | { component: string; data: HumanViewData } | No | Optional 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
| Method | Additional properties | Successful value | Waits for an answer? |
|---|---|---|---|
Human.confirm(options) | None | boolean | Yes |
Human.text(options) | None | string | Yes |
Human.select(options) | Required options: readonly HumanChoiceOption[] | Selected option's value | Yes |
Human.notify(options) | None | void | No |
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>
| Property | Type | Required | Meaning |
|---|---|---|---|
value | Value (a string) | Yes | Machine-readable result, such as "revise" |
label | string | Yes | Visible option label |
description | string | No | Additional 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
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.
| Property | Type | Required | Meaning |
|---|---|---|---|
key | string | Yes | Stable nonempty request identity |
title | string | Yes | Nonempty visible title |
description | string | No | Review instructions |
view | HumanForm<Id, A, I, R, RI> | Yes | Form created with HumanView.make |
data | A | Yes | Decoded 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.18In 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.
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"}.
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.
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.
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.