NewWacht Bench is live — AI-assisted development for Wacht
GuidesTasks

Realtime UI

Surface in-flight task state, deliverables, pending questions, and approvals in your UI as they happen.

A task runs without the user in the loop. Your UI's job is to reflect its state as it changes — status, deliverables, pending questions, approvals, comments — without making the user refresh. The React SDK gives you live hooks built on SWR; wire them in and the UI tracks reality.

The hooks live in @wacht/jsx and are re-exported from @wacht/nextjs. They revalidate on a 15-second interval with focus revalidation off, so this is near-realtime, not push. For tighter latency, see Polling and revalidation.

The hooks

HookReturnsReflects
useProjectTaskBoardItem(projectId, taskId)the task object plus action methodsstatus, deliverables, pending_question, pending_approval, assignments, workspace
useProjectTaskBoardItemComments(projectId, taskId)comments array plus createCommentcomments added or resolved
useNotifications()the notification inboxnew notifications

useProjectTaskBoardItem is the workhorse. It takes both projectId and taskId — both required — and returns the board item plus the methods you act on it with: submitAnswer, submitApproval, updateItem, cancelItem, and the workspace accessors (taskWorkspace, getTaskWorkspaceFile, listTaskWorkspaceDirectory, downloadTaskWorkspaceFile). There are no separate workspace-file hooks; file access is methods on this one.

Showing a task end to end

One hook drives status, deliverables, pending questions, and approvals; a second drives comments.

"use client";
import {
  useProjectTaskBoardItem,
  useProjectTaskBoardItemComments,
} from "@wacht/nextjs";

export function TaskPage({ projectId, taskId }: { projectId: string; taskId: string }) {
  const { item, loading, submitAnswer, submitApproval } = useProjectTaskBoardItem(
    projectId,
    taskId,
  );
  const { comments, createComment } = useProjectTaskBoardItemComments(projectId, taskId);

  if (loading || !item) return <div>Loading…</div>;

  return (
    <article>
      <header>
        <h1>{item.title}</h1>
        <StatusBadge status={item.status} />
      </header>

      {item.pending_question && (
        <PendingQuestionCard question={item.pending_question} onAnswer={submitAnswer} />
      )}

      {item.pending_approval && (
        <ApprovalCard approval={item.pending_approval} onDecide={submitApproval} />
      )}

      <DeliverablesList deliverables={item.deliverables ?? []} taskId={taskId} />

      <CommentsThread comments={comments ?? []} onPost={createComment} />
    </article>
  );
}

Two subscriptions, both revalidating on the 15-second interval. submitAnswer and submitApproval mutate the item after they resolve, so the card disappears as soon as the field clears.

Status changes

item.status walks the lifecycle. Render it with a badge:

function StatusBadge({ status }: { status: string }) {
  const color = {
    pending: "gray",
    available: "blue",
    claimed: "blue",
    in_progress: "amber",
    completed: "green",
    blocked: "red",
    needs_clarification: "yellow",
    rejected: "red",
    cancelled: "gray",
  }[status] ?? "gray";
  return <span className={`badge badge-${color}`}>{status}</span>;
}

in_progress can sit for a long time on a substantial task. Give the user something to watch — the latest deliverable, or the assignments list from the same hook (assignments) showing which lane is active.

Deliverables appearing

When a coordinator marks a task completed, an entry appends to item.deliverables. The next revalidation picks it up and the list re-renders:

function DeliverablesList({ deliverables, taskId }) {
  if (deliverables.length === 0) {
    return <div className="empty">No deliverables yet.</div>;
  }
  return (
    <ol>
      {deliverables.map((d, i) => (
        <DeliverableCard key={i} deliverable={d} taskId={taskId} />
      ))}
    </ol>
  );
}

Each entry's artifacts is a list of objects with a path. See Deliverables and the journal for the full shape and how to link artifacts back to the workspace.

Pending questions

When the agent calls ask_user, the runtime sets item.pending_question. Render the questions as a form and submit through submitAnswer:

function PendingQuestionCard({ question, onAnswer }) {
  // question.questions is an array of { id, text, answer_kind }
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onAnswer({ answers: buildAnswers() }); // [{ question_id, value }]
      }}
    >
      {question.questions.map((q) => (
        <QuestionInput key={q.id} question={q} />
      ))}
      <details>
        <summary>Or answer in your own words</summary>
        <textarea name="freeform_text" maxLength={4000} />
      </details>
      <button>Submit</button>
    </form>
  );
}

submitAnswer takes an AnswerSubmission: either answers (a list of { question_id, value }) or freeform_text — supply one. The agent resumes on its next iteration with your answer in context. Submitting clears pending_question, so the card unmounts on the following revalidation.

Approvals

When the agent calls a gated tool, the runtime sets item.pending_approval. Render the requested tools and decide each through submitApproval:

function ApprovalCard({ approval, onDecide }) {
  return (
    <div>
      <p>{approval.description}</p>
      <ul>
        {approval.tools.map((t) => (
          <li key={t.tool_id}>
            <strong>{t.tool_name}</strong>: {t.tool_description}
            <button onClick={() => onDecide([{ tool_name: t.tool_name, mode: "allow_once" }])}>
              Allow once
            </button>
            <button onClick={() => onDecide([{ tool_name: t.tool_name, mode: "allow_always" }])}>
              Allow always for this thread
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

submitApproval takes a list of { tool_name, mode } decisions, where mode is allow_once or allow_always. It reads the request_message_id off the current pending_approval for you, and throws if there is no pending approval — only render the card while item.pending_approval is set. allow_always persists the grant for the thread, so the agent won't re-prompt for that tool.

Comments

Comments are a side channel on the board item — useful for humans annotating the agent's work. Posting one preempts any active assignment, so the agent's next iteration picks up the new context.

function CommentsThread({ comments, onPost }) {
  return (
    <section>
      {comments.map((c) => (
        <Comment key={c.id} comment={c} />
      ))}
      <CommentForm onSubmit={(body, files) => onPost(body, files)} />
    </section>
  );
}

createComment(body, files?) accepts optional file attachments; they land in the workspace under /task/uploads/ (see File uploads). Comments are not part of the agent's conversation history by default — to feed a comment into the agent, post it through the attachment endpoint that preempts the assignment, or have your agent prompt read comments through the API.

Notifications

For events outside the current task — a scheduled task finished, an approval is waiting, a sub-agent flagged something — use the notification inbox:

import { useNotifications } from "@wacht/nextjs";

function NotificationBell() {
  const { notifications, markAsRead, markAllAsRead } = useNotifications();
  const unread = notifications.filter((n) => !n.is_read).length;
  return (
    <Dropdown trigger={<Bell count={unread} />}>
      {notifications.map((n) => (
        <NotificationItem key={n.id} notification={n} onClick={() => markAsRead(n.id)} />
      ))}
      <button onClick={markAllAsRead}>Mark all read</button>
    </Dropdown>
  );
}

useNotifications() returns { notifications, hasMore, loadMore, markAsRead, markAllAsRead, refetch, ... }. It paginates, so call loadMore for older entries; derive unread from the notifications rather than expecting an aggregate count on the hook. The backend emits notifications through the platform-events tool or your own backend call — see the Notifications guide.

Polling and revalidation

The task hooks revalidate on a 15-second interval with focus revalidation off. That bounds latency to roughly the interval; it is not server push. To tighten it:

  • Drop the SWR refreshInterval on hot screens (e.g. 2s while in_progress).
  • Stop revalidating once the task hits a terminal status — completed, rejected, cancelled, blocked — to avoid polling a settled task forever.
  • For genuine server-pushed updates of an agent's live reasoning, the conversation stream on threads is the channel, not the task hooks.

Pitfalls

An agent emits a lot of intermediate tool calls. Surfacing them all overwhelms the user; show the deliverable and the status, not the raw tool stream.

Polling at high frequency forever costs money. Cancel the interval when the task settles.

The journal is the agent's memory; deliverables are the user's view. Different audiences — don't render the journal where a user expects results.

When a task is pending for its first few seconds, render "starting…" rather than an empty deliverables list.

Where to go next

On this page