Skip to main content

Human-in-the-Loop

AI applications frequently need human judgment at key decision points. A user might approve a deployment, choose between generated options, or provide feedback on a draft. Idyllic supports these patterns through state fields that signal pending input, allowing your system to wait efficiently until the user responds.

The Pattern

Set state indicating you’re waiting for input, then return from the action. The Durable Object hibernates with zero compute cost while the user decides. When they respond, their input arrives as a new action call:
export default class DocumentWriter extends AgenticSystem {
  @field draft = stream<string>('');
  @field status: 'idle' | 'drafting' | 'awaiting_approval' | 'published' = 'idle';
  @field pendingConfirm: { message: string } | null = null;

  @action()
  async createDocument(topic: string) {
    this.status = 'drafting';

    for await (const chunk of ai.stream(`Write about: ${topic}`)) {
      this.draft.append(chunk);
    }
    this.draft.complete();

    // Signal waiting for input
    this.status = 'awaiting_approval';
    this.pendingConfirm = { message: 'Publish this document?' };
    // Action completes—DO can hibernate
  }

  @action()
  async respond(approved: boolean, feedback?: string) {
    this.pendingConfirm = null;

    if (approved) {
      await this.publish();
      this.status = 'published';
    } else if (feedback) {
      this.status = 'drafting';
      this.draft.reset();
      for await (const chunk of ai.stream(`Revise based on: ${feedback}`)) {
        this.draft.append(chunk);
      }
      this.draft.complete();
      this.status = 'awaiting_approval';
      this.pendingConfirm = { message: 'Publish this revision?' };
    }
  }
}

How It Works

There’s no special pause primitive. When your action sets pendingConfirm, that broadcasts to clients. The action completes normally. The Durable Object becomes eligible for hibernation. On the client, the component re-renders. When pendingConfirm has data, render the dialog. When the user responds, call actions.respond(), which wakes the Durable Object and continues the workflow. The system can wait indefinitely at zero compute cost.

Confirmation UI

Render based on pendingConfirm state:
function DocumentEditor() {
  const { draft, pendingConfirm, respond } = useSystem<DocumentWriter>();

  return (
    <div>
      <div className="draft">
        {draft.current}
        {draft.status === 'streaming' && <Cursor />}
      </div>

      {pendingConfirm && (
        <ConfirmDialog
          message={pendingConfirm.message}
          onApprove={() => respond(true)}
          onReject={(feedback) => respond(false, feedback)}
        />
      )}
    </div>
  );
}

Multi-Step Approval

Track approval stages:
@field stage: 'drafting' | 'content_review' | 'legal_review' | 'published' = 'drafting';
@field pendingConfirm: { message: string; stage: string } | null = null;

@action()
async submitForReview() {
  this.stage = 'content_review';
  this.pendingConfirm = { message: 'Content approved?', stage: 'content' };
}

@action()
async approveStage(approved: boolean) {
  if (!approved) {
    this.stage = 'drafting';
    this.pendingConfirm = null;
    return;
  }

  if (this.stage === 'content_review') {
    this.stage = 'legal_review';
    this.pendingConfirm = { message: 'Legal approved?', stage: 'legal' };
  } else if (this.stage === 'legal_review') {
    this.stage = 'published';
    this.pendingConfirm = null;
    await this.publish();
  }
}

Timeouts

Use schedule() for timeout behavior:
@field pendingConfirm: { message: string; expiresAt: number } | null = null;

@action()
async requestApproval() {
  this.pendingConfirm = {
    message: 'Approve deployment?',
    expiresAt: Date.now() + 24 * 60 * 60 * 1000,
  };
  this.schedule(24 * 60 * 60 * 1000, 'checkTimeout');
}

async checkTimeout() {
  if (this.pendingConfirm && Date.now() > this.pendingConfirm.expiresAt) {
    this.pendingConfirm = null;
    this.status = 'timed_out';
  }
}

FAQ

What if the user never responds?

The system waits indefinitely at zero cost. Implement timeouts if needed.

How long can the system wait?

Indefinitely. State persists in SQLite. Close your browser, return weeks later—the pending confirmation is still there.

Can I resume in a different browser?

Yes. State persists across sessions and devices. Same instance ID shows same state.

What about authentication?

Verify user identity in the respond action. Check connection metadata or pass user info as arguments.