> ## Documentation Index
> Fetch the complete documentation index at: https://docs.guidinghand.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart

> Create a session, send the invite link, run a task on the customer's computer and read the result.

This walks through one complete task with the `/v1` API: a session for a customer, their computer connecting, a task that asks a question and an approval, and the finished result with its replay.

Every step has cURL, Node and Python. The Node code needs Node 18 or later (it uses the built-in `fetch`). The Python code needs `requests` (`pip install requests`). A single script with all of it is at the [end of the page](#the-whole-thing).

<Tip>
  With an SDK the whole flow is about 15 lines: `npm install guidinghand` ([JavaScript](/sdks/javascript)) or `pip install guidinghand` ([Python](/sdks/python)). `tasks.run()` starts a task and answers its questions and approvals with your callbacks. This page shows the HTTP underneath.
</Tip>

<Steps>
  <Step title="Get an API key">
    Sign in at [guidinghand.ai/console](https://guidinghand.ai/console) and open **Settings → API keys**. Create a key and copy it: it starts with `gh_live_` and is shown once. Creating keys needs the admin or owner role in the org.

    ```bash theme={null}
    export GUIDINGHAND_API_KEY="gh_live_..."
    ```

    Every request sends it as a bearer token. The Node and Python snippets below share this small helper, which turns error responses into exceptions:

    <CodeGroup>
      ```bash cURL theme={null}
      # Each cURL example sends these headers:
      #   -H "Authorization: Bearer $GUIDINGHAND_API_KEY"
      #   -H "Content-Type: application/json"   (when there is a body)
      curl https://guidinghand.ai/v1/agents \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY"
      ```

      ```javascript Node theme={null}
      const BASE = 'https://guidinghand.ai';
      const KEY = process.env.GUIDINGHAND_API_KEY;

      async function gh(method, path, body) {
        const res = await fetch(BASE + path, {
          method,
          headers: {
            Authorization: `Bearer ${KEY}`,
            ...(body ? { 'Content-Type': 'application/json' } : {}),
          },
          body: body ? JSON.stringify(body) : undefined,
        });
        const data = await res.json();
        if (!res.ok) throw Object.assign(new Error(`${res.status} ${data.error.type}: ${data.error.message}`), { code: data.error.code });
        return data;
      }
      ```

      ```python Python theme={null}
      import os
      import requests

      BASE = "https://guidinghand.ai"
      http = requests.Session()
      http.headers["Authorization"] = f"Bearer {os.environ['GUIDINGHAND_API_KEY']}"


      def gh(method, path, body=None, params=None):
          r = http.request(method, BASE + path, json=body, params=params, timeout=70)
          data = r.json()
          if not r.ok:
              err = RuntimeError(f"{r.status_code} {data['error']['type']}: {data['error']['message']}")
              err.code = data["error"].get("code")  # e.g. "already_answered"
              raise err
          return data
      ```
    </CodeGroup>

    <Tip>
      To try things without touching production billing, use `https://dev.guidinghand.ai` as the base URL with a key made in the dev console. Dev runs Stripe in test mode.
    </Tip>
  </Step>

  <Step title="Create an agent (optional)">
    An agent holds your instructions, reasoning effort and the greeting the customer sees on the invite page. You can skip this step: every org has a `default` agent that works without setup.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://guidinghand.ai/v1/agents \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "agent_id": "billing",
          "name": "Billing help",
          "instructions": "Our billing app is Acme Billing. Open it from the Dock before anything else.",
          "effort": "medium",
          "greeting": "Hi, this is Acme support. We will fix your billing settings together."
        }'
      ```

      ```javascript Node theme={null}
      const agent = await gh('POST', '/v1/agents', {
        agent_id: 'billing',
        name: 'Billing help',
        instructions: 'Our billing app is Acme Billing. Open it from the Dock before anything else.',
        effort: 'medium',
        greeting: 'Hi, this is Acme support. We will fix your billing settings together.',
      });
      console.log(agent.invite_url_template);
      ```

      ```python Python theme={null}
      agent = gh("POST", "/v1/agents", {
          "agent_id": "billing",
          "name": "Billing help",
          "instructions": "Our billing app is Acme Billing. Open it from the Dock before anything else.",
          "effort": "medium",
          "greeting": "Hi, this is Acme support. We will fix your billing settings together.",
      })
      print(agent["invite_url_template"])
      ```
    </CodeGroup>

    `agent_id` becomes part of invite links, so it is lowercase letters, numbers and dashes. Creating an agent needs the admin role; an API key has it. An `agent_id` that already exists returns `409 conflict`. See [Agents](/concepts/agents).
  </Step>

  <Step title="Create a session and send the invite link">
    A session is one pairing code for one computer. Put your own ids in `metadata` so you can match the session to a ticket later.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://guidinghand.ai/v1/sessions \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"agent_id": "billing", "metadata": {"ticket_id": "T-1042"}}'
      ```

      ```javascript Node theme={null}
      const session = await gh('POST', '/v1/sessions', {
        agent_id: 'billing',
        metadata: { ticket_id: 'T-1042' },
      });
      console.log(`Send this link to the customer: ${session.invite_url}`);
      ```

      ```python Python theme={null}
      session = gh("POST", "/v1/sessions", {"agent_id": "billing", "metadata": {"ticket_id": "T-1042"}})
      print("Send this link to the customer:", session["invite_url"])
      ```
    </CodeGroup>

    ```json Response theme={null}
    {
      "object": "session",
      "session_id": "K7QM-24XP",
      "code": "K7QM-24XP",
      "agent_id": "billing",
      "invite_url": "https://guidinghand.ai/acme/billing/K7QM-24XP",
      "status": "waiting",
      "device": null,
      "metadata": { "ticket_id": "T-1042" },
      "created_at": "2026-09-26T15:03:36.042Z",
      "paired_at": null,
      "last_active_at": "2026-09-26T15:03:36.042Z",
      "expires_at": "2026-09-29T15:03:36.042Z",
      "task_count": 0,
      "session_token": "gh_sk_..."
    }
    ```

    Send `invite_url` to the customer through whatever channel you already use: chat, email or the ticket. The page walks them through installing GuidingHand, granting the macOS permissions, and clicking **Connect**. The `session_id` is the code itself.

    `session_token` is for the older [session-token API](/api-reference/introduction#the-older-session-token-api). It is shown once; you don't need it for `/v1`.
  </Step>

  <Step title="Wait until the computer is connected">
    Tasks need `status: connected`. Either poll the session, or set up a [webhook](/guides/webhooks) and wait for `session.connected`.

    <CodeGroup>
      ```bash cURL theme={null}
      curl https://guidinghand.ai/v1/sessions/K7QM-24XP \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY"
      ```

      ```javascript Node theme={null}
      const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

      let s = session;
      while (s.status !== 'connected') {
        if (s.status === 'expired') throw new Error('The code expired before anyone connected.');
        await sleep(3000);
        s = await gh('GET', `/v1/sessions/${session.session_id}`);
      }
      console.log(`Connected: ${s.device?.name ?? s.device?.os}`);
      ```

      ```python Python theme={null}
      import time

      s = session
      while s["status"] != "connected":
          if s["status"] == "expired":
              raise RuntimeError("The code expired before anyone connected.")
          time.sleep(3)
          s = gh("GET", f"/v1/sessions/{session['session_id']}")
      print("Connected:", (s["device"] or {}).get("name") or (s["device"] or {}).get("os"))
      ```
    </CodeGroup>

    Once connected, `device` tells you the operating system (`mac` or `windows`), the computer's name and its screen size.
  </Step>

  <Step title="Start a task">
    Describe the outcome in plain language. `request_id` is an idempotency key: if your request times out and you send it again with the same `request_id`, you get the same task back instead of a second one.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://guidinghand.ai/v1/sessions/K7QM-24XP/tasks \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"prompt": "Turn on Dark Mode", "request_id": "T-1042-dark-mode"}'
      ```

      ```javascript Node theme={null}
      let task = await gh('POST', `/v1/sessions/${session.session_id}/tasks`, {
        prompt: 'Turn on Dark Mode',
        request_id: 'T-1042-dark-mode',
      });
      console.log(task.task_id, task.status);
      ```

      ```python Python theme={null}
      task = gh("POST", f"/v1/sessions/{session['session_id']}/tasks", {
          "prompt": "Turn on Dark Mode",
          "request_id": "T-1042-dark-mode",
      })
      print(task["task_id"], task["status"])
      ```
    </CodeGroup>

    ```json Response theme={null}
    {
      "object": "task",
      "task_id": "task_mW8mcFPUN7Of",
      "session_id": "K7QM-24XP",
      "agent_id": "billing",
      "status": "running",
      "done": false,
      "prompt": "Turn on Dark Mode",
      "result": null,
      "error": null,
      "pending": null,
      "cursor": 1,
      "metadata": {},
      "created_at": "2026-09-26T15:05:37.559Z",
      "updated_at": "2026-09-26T15:05:37.562Z",
      "active_seconds": 0,
      "billed_minutes": null,
      "replay_url": "https://guidinghand.ai/console/acme/tasks/task_mW8mcFPUN7Of"
    }
    ```

    One task runs at a time per computer. Starting another while one runs returns `409 conflict` with the running task's id in `error.active_task_id`.
  </Step>

  <Step title="Follow the task and answer what it asks">
    `GET /v1/tasks/{task_id}/events` returns the events after `after`. With `wait_ms` (up to 55000) it holds the request open until something new happens, so a simple loop gets every event as it happens. Pass the returned `cursor` as `after` on the next call, and stop when `task.done` is `true`.

    When the agent needs a person, `task.pending` is set and the task waits:

    * `pending.type: "question"`: send its `question_id` and your `answer`. The customer usually sees the question on their screen too (`pending.customer_can_answer`) and can answer it there. The first answer is used; answering after them returns `409` with `code: "already_answered"` (or `"not_pending"` once that question has closed, say because they answered it and the agent has moved on), which just means it's done.
    * `pending.type: "approval"`: send its `approval_id` and a `decision` of `approve` or `deny`. The customer usually sees the request on their screen too (`pending.customer_can_approve`) and can allow it or not there. As with questions, the first decision is used, and `already_answered` or `not_pending` just means it's done.

    <CodeGroup>
      ```bash cURL theme={null}
      # Follow: repeat with after=<cursor from the last response>
      curl "https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of/events?after=0&wait_ms=25000" \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY"

      # Answer a question
      curl -X POST https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of/respond \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"question_id": "q_yrJaMMY1ZMM", "answer": "Work"}'

      # Decide an approval
      curl -X POST https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of/respond \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"approval_id": "appr_OZ0pj8lNbZk", "decision": "approve"}'
      ```

      ```javascript Node theme={null}
      import readline from 'node:readline/promises';
      const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

      let after = 0;
      const answered = new Set();
      while (!task.done) {
        const page = await gh('GET', `/v1/tasks/${task.task_id}/events?after=${after}&wait_ms=25000`);
        for (const e of page.data) console.log(`[${e.type}] ${e.message ?? ''}`);
        after = page.cursor;
        task = page.task;

        const p = task.pending;
        if (p && !answered.has(p.question_id ?? p.approval_id)) {
          if (p.type === 'question') {
            const answer = await rl.question(`${p.question} > `);
            await gh('POST', `/v1/tasks/${task.task_id}/respond`, { question_id: p.question_id, answer })
              .catch((e) => { if (!['already_answered', 'not_pending'].includes(e.code)) throw e; }); // answered on their screen first, or already closed
            answered.add(p.question_id);
          } else {
            const where = p.customer_can_approve ? ' (they can decide on their screen too)' : '';
            const yes = await rl.question(`Approve "${p.action}"${where}? [y/N] > `);
            await gh('POST', `/v1/tasks/${task.task_id}/respond`, {
              approval_id: p.approval_id,
              decision: yes.trim().toLowerCase() === 'y' ? 'approve' : 'deny',
            }).catch((e) => { if (!['already_answered', 'not_pending'].includes(e.code)) throw e; }); // decided on their screen first, or already closed
            answered.add(p.approval_id);
          }
        }
      }
      rl.close();
      ```

      ```python Python theme={null}
      after = 0
      answered = set()
      while not task["done"]:
          page = gh("GET", f"/v1/tasks/{task['task_id']}/events", params={"after": after, "wait_ms": 25000})
          for e in page["data"]:
              print(f"[{e['type']}] {e.get('message', '')}")
          after = page["cursor"]
          task = page["task"]

          p = task["pending"]
          if p and (p.get("question_id") or p.get("approval_id")) not in answered:
              if p["type"] == "question":
                  answer = input(f"{p['question']} > ")
                  try:
                      gh("POST", f"/v1/tasks/{task['task_id']}/respond", {"question_id": p["question_id"], "answer": answer})
                  except RuntimeError as e:
                      if getattr(e, "code", None) not in ("already_answered", "not_pending"):  # answered on their screen first, or already closed
                          raise
                  answered.add(p["question_id"])
              else:
                  where = " (they can decide on their screen too)" if p.get("customer_can_approve") else ""
                  yes = input(f"Approve \"{p['action']}\"{where}? [y/N] > ")
                  try:
                      gh("POST", f"/v1/tasks/{task['task_id']}/respond", {
                          "approval_id": p["approval_id"],
                          "decision": "approve" if yes.strip().lower() == "y" else "deny",
                      })
                  except RuntimeError as e:
                      if getattr(e, "code", None) not in ("already_answered", "not_pending"):  # decided on their screen first, or already closed
                          raise
                  answered.add(p["approval_id"])
      ```
    </CodeGroup>

    A response while the agent waits on a question looks like this:

    ```json Response theme={null}
    {
      "data": [
        { "cursor": 1, "type": "started", "message": "Task received. Starting on the connected computer…", "ts": "2026-09-26T15:05:37.561Z" },
        { "cursor": 2, "type": "progress", "message": "Connected to the computer. Looking at the screen…", "ts": "2026-09-26T15:05:37.569Z" },
        { "cursor": 3, "type": "action", "message": "Click at (412, 88)", "ts": "2026-09-26T15:05:39.584Z",
          "data": { "action": { "type": "click", "button": "left", "x": 412, "y": 88 } } },
        { "cursor": 4, "type": "question", "message": "Which account?", "ts": "2026-09-26T15:05:41.593Z",
          "data": { "question_id": "q_yrJaMMY1ZMM", "question": "Which account?", "options": ["Work", "Home"] } }
      ],
      "cursor": 4,
      "task": {
        "object": "task",
        "task_id": "task_mW8mcFPUN7Of",
        "session_id": "K7QM-24XP",
        "agent_id": "billing",
        "status": "waiting_for_user",
        "done": false,
        "prompt": "Turn on Dark Mode",
        "result": null,
        "error": null,
        "pending": {
          "type": "question",
          "question_id": "q_yrJaMMY1ZMM",
          "question": "Which account?",
          "options": ["Work", "Home"],
          "customer_can_answer": true
        },
        "cursor": 4,
        "metadata": {},
        "created_at": "2026-09-26T15:05:37.559Z",
        "updated_at": "2026-09-26T15:05:41.593Z",
        "active_seconds": 4,
        "billed_minutes": null,
        "replay_url": "https://guidinghand.ai/console/acme/tasks/task_mW8mcFPUN7Of"
      }
    }
    ```

    In a real integration the question usually goes to your support agent's screen or to your own AI agent, not to a terminal prompt, or you leave it to the customer, who can answer it on their screen. `answered` keeps the loop from answering the same question twice if the next page arrives before the task moves on.

    <Note>
      A task waiting on a question or approval doesn't use agent minutes. Only running time is billed. See [Tasks](/concepts/tasks#billing).
    </Note>
  </Step>

  <Step title="Read the result">
    When `task.done` is `true`, `status` is `completed`, `failed` or `stopped`. A completed task has the agent's summary in `result`. A failed task has the reason in `error`. For a stopped task, the reason is the message of its last event (`stopped`), for example "Stopped by the person at the computer."

    <CodeGroup>
      ```bash cURL theme={null}
      curl https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY"
      ```

      ```javascript Node theme={null}
      console.log(`${task.status}: ${task.result ?? task.error ?? ''}`);
      ```

      ```python Python theme={null}
      print(f"{task['status']}: {task['result'] or task['error'] or ''}")
      ```
    </CodeGroup>

    ```json Response theme={null}
    {
      "object": "task",
      "task_id": "task_mW8mcFPUN7Of",
      "session_id": "K7QM-24XP",
      "agent_id": "billing",
      "status": "completed",
      "done": true,
      "prompt": "Turn on Dark Mode",
      "result": "Dark Mode is on. I switched Appearance to Dark in System Settings.",
      "error": null,
      "pending": null,
      "cursor": 14,
      "metadata": {},
      "created_at": "2026-09-26T15:05:37.559Z",
      "updated_at": "2026-09-26T15:06:52.106Z",
      "active_seconds": 41,
      "billed_minutes": 1,
      "replay_url": "https://guidinghand.ai/console/acme/tasks/task_mW8mcFPUN7Of",
      "recording": { "frames": 9 }
    }
    ```

    Add `?include=events` to get every event with the task.
  </Step>

  <Step title="Open the replay">
    `replay_url` opens the task in the console: every screen the agent saw, with its cursor and clicks, next to the timeline of events. Anyone in the org can open it after signing in.

    To get the screens yourself, `GET /v1/tasks/{task_id}/recording` lists the frames and each frame's `url` returns the PNG. See [Recordings](/concepts/recordings).

    <CodeGroup>
      ```bash cURL theme={null}
      curl https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of/recording \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY"

      curl https://guidinghand.ai/v1/tasks/task_mW8mcFPUN7Of/recording/1 \
        -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
        -o frame-1.png
      ```

      ```javascript Node theme={null}
      console.log(`Replay: ${task.replay_url}`);
      const rec = await gh('GET', `/v1/tasks/${task.task_id}/recording`);
      console.log(`${rec.frames.length} frames`);
      ```

      ```python Python theme={null}
      print("Replay:", task["replay_url"])
      rec = gh("GET", f"/v1/tasks/{task['task_id']}/recording")
      print(len(rec["frames"]), "frames")
      ```
    </CodeGroup>
  </Step>
</Steps>

## The whole thing

Save one of these and run it with `GUIDINGHAND_API_KEY` set. It uses the `default` agent, prints the invite link, waits for the computer, runs one task and answers questions and approvals from your terminal (the customer can also answer the questions on their screen). Set `GUIDINGHAND_API_URL=https://dev.guidinghand.ai` to run it against dev.

<CodeGroup>
  ```javascript quickstart.mjs theme={null}
  // node quickstart.mjs (Node 18 or later)
  import readline from 'node:readline/promises';

  const BASE = process.env.GUIDINGHAND_API_URL ?? 'https://guidinghand.ai';
  const KEY = process.env.GUIDINGHAND_API_KEY;
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  async function gh(method, path, body) {
    const res = await fetch(BASE + path, {
      method,
      headers: {
        Authorization: `Bearer ${KEY}`,
        ...(body ? { 'Content-Type': 'application/json' } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const data = await res.json();
    if (!res.ok) throw Object.assign(new Error(`${res.status} ${data.error.type}: ${data.error.message}`), { code: data.error.code });
    return data;
  }

  // 1. A session, and the invite link for the customer
  const session = await gh('POST', '/v1/sessions', {
    agent_id: 'default',
    metadata: { ticket_id: 'T-1042' },
  });
  console.log(`Send this link to the customer: ${session.invite_url}`);

  // 2. Wait for their computer to connect
  let s = session;
  while (s.status !== 'connected') {
    if (s.status === 'expired') throw new Error('The code expired before anyone connected.');
    await sleep(3000);
    s = await gh('GET', `/v1/sessions/${session.session_id}`);
  }
  console.log(`Connected: ${s.device?.name ?? s.device?.os}`);

  // 3. Start a task
  let task = await gh('POST', `/v1/sessions/${session.session_id}/tasks`, {
    prompt: 'Turn on Dark Mode',
    request_id: `T-1042-dark-mode`,
  });

  // 4. Follow it, answering questions and approvals
  let after = 0;
  const answered = new Set();
  while (!task.done) {
    const page = await gh('GET', `/v1/tasks/${task.task_id}/events?after=${after}&wait_ms=25000`);
    for (const e of page.data) console.log(`[${e.type}] ${e.message ?? ''}`);
    after = page.cursor;
    task = page.task;

    const p = task.pending;
    if (p && !answered.has(p.question_id ?? p.approval_id)) {
      if (p.type === 'question') {
        const hint = p.options.length ? ` (${p.options.join(' / ')})` : '';
        const where = p.customer_can_answer ? ' (they can answer on their screen too)' : '';
        const answer = await rl.question(`${p.question}${hint}${where} > `);
        await gh('POST', `/v1/tasks/${task.task_id}/respond`, { question_id: p.question_id, answer })
          .catch((e) => { if (!['already_answered', 'not_pending'].includes(e.code)) throw e; }); // answered on their screen first, or already closed
        answered.add(p.question_id);
      } else {
        const where = p.customer_can_approve ? ' (they can decide on their screen too)' : '';
        const yes = await rl.question(`Approve "${p.action}" (risk: ${p.risk})${where}? [y/N] > `);
        await gh('POST', `/v1/tasks/${task.task_id}/respond`, {
          approval_id: p.approval_id,
          decision: yes.trim().toLowerCase() === 'y' ? 'approve' : 'deny',
        }).catch((e) => { if (!['already_answered', 'not_pending'].includes(e.code)) throw e; }); // decided on their screen first, or already closed
        answered.add(p.approval_id);
      }
    }
  }

  // 5. The result and the replay
  console.log(`${task.status}: ${task.result ?? task.error ?? ''}`);
  console.log(`Replay: ${task.replay_url}`);
  rl.close();
  ```

  ```python quickstart.py theme={null}
  # pip install requests && python quickstart.py
  import os
  import time

  import requests

  BASE = os.environ.get("GUIDINGHAND_API_URL", "https://guidinghand.ai")
  http = requests.Session()
  http.headers["Authorization"] = f"Bearer {os.environ['GUIDINGHAND_API_KEY']}"


  def gh(method, path, body=None, params=None):
      r = http.request(method, BASE + path, json=body, params=params, timeout=70)
      data = r.json()
      if not r.ok:
          err = RuntimeError(f"{r.status_code} {data['error']['type']}: {data['error']['message']}")
          err.code = data["error"].get("code")  # e.g. "already_answered"
          raise err
      return data


  # 1. A session, and the invite link for the customer
  session = gh("POST", "/v1/sessions", {"agent_id": "default", "metadata": {"ticket_id": "T-1042"}})
  print("Send this link to the customer:", session["invite_url"])

  # 2. Wait for their computer to connect
  s = session
  while s["status"] != "connected":
      if s["status"] == "expired":
          raise RuntimeError("The code expired before anyone connected.")
      time.sleep(3)
      s = gh("GET", f"/v1/sessions/{session['session_id']}")
  print("Connected:", (s["device"] or {}).get("name") or (s["device"] or {}).get("os"))

  # 3. Start a task
  task = gh("POST", f"/v1/sessions/{session['session_id']}/tasks", {
      "prompt": "Turn on Dark Mode",
      "request_id": "T-1042-dark-mode",
  })

  # 4. Follow it, answering questions and approvals
  after = 0
  answered = set()
  while not task["done"]:
      page = gh("GET", f"/v1/tasks/{task['task_id']}/events", params={"after": after, "wait_ms": 25000})
      for e in page["data"]:
          print(f"[{e['type']}] {e.get('message', '')}")
      after = page["cursor"]
      task = page["task"]

      p = task["pending"]
      if p and (p.get("question_id") or p.get("approval_id")) not in answered:
          if p["type"] == "question":
              hint = f" ({' / '.join(p['options'])})" if p["options"] else ""
              where = " (they can answer on their screen too)" if p.get("customer_can_answer") else ""
              answer = input(f"{p['question']}{hint}{where} > ")
              try:
                  gh("POST", f"/v1/tasks/{task['task_id']}/respond", {"question_id": p["question_id"], "answer": answer})
              except RuntimeError as e:
                  if getattr(e, "code", None) not in ("already_answered", "not_pending"):  # answered on their screen first, or already closed
                      raise
              answered.add(p["question_id"])
          else:
              where = " (they can decide on their screen too)" if p.get("customer_can_approve") else ""
              yes = input(f"Approve \"{p['action']}\" (risk: {p['risk']}){where}? [y/N] > ")
              try:
                  gh("POST", f"/v1/tasks/{task['task_id']}/respond", {
                      "approval_id": p["approval_id"],
                      "decision": "approve" if yes.strip().lower() == "y" else "deny",
                  })
              except RuntimeError as e:
                  if getattr(e, "code", None) not in ("already_answered", "not_pending"):  # decided on their screen first, or already closed
                      raise
              answered.add(p["approval_id"])

  # 5. The result and the replay
  print(f"{task['status']}: {task['result'] or task['error'] or ''}")
  print("Replay:", task["replay_url"])
  ```
</CodeGroup>

## Next

* Replace polling with [webhooks](/guides/webhooks).
* Let your own LLM agent drive GuidingHand: [AI agents](/guides/ai-agents).
* Handle [errors](/guides/errors) such as a computer that went offline or a plan limit.
