Let an LLM run your workspace

Everything on this site so far treats Signal as a data source: you fetch, you decide, you act. This playbook is the other shape — giving a model a conversational surface and letting it operate the workspace, with the guardrails that make that safe rather than exciting.

Read this before building it. The interesting part is not what an agent can do. It is what it is prevented from doing, and why.


Two doors, and they are not equivalent

API keySigned-in session
REST readsyes, read scopeyes
Ask questionsyes, copilot scopeyes
Change anythingneveryes, with confirmation

An sk_sig_ key reaches the copilot's read tools and no others, whatever it is asked and however it is asked. This is not a prompt instruction — the tool list handed to the planner is filtered by how you authenticated, so a key-authed caller cannot name a mutating tool, let alone invoke one.

That means: an autonomous agent running on a key can analyse and report, and cannot change your workspace. For most automations that is exactly right, and it is the configuration to reach for first.


Pattern 1 — A read-only analyst (start here)

import requests

def ask(question: str) -> dict:
    r = requests.post(
        "https://app.signal.geysera.com/agent-api/signal/copilot/ask",
        headers={"Authorization": f"Bearer {COPILOT_KEY}"},
        json={"question": question},
        timeout=90,
    )
    r.raise_for_status()
    return r.json()

The response is designed for a machine to consume, not just a human to read:

{
  "answer": "Revenue in the last 7 days was ...",
  "refusal": null,
  "clarification": null,
  "question_kind": "descriptive",
  "plan": { "reasoning": "...", "calls": ["data_coverage", "revenue_and_aov"] },
  "trace": [ { "tool": "revenue_and_aov", "data": { "...": "..." } } ],
  "disclosures": ["..."],
  "limits": [],
  "warnings": [],
  "pending_confirmation": null,
  "mutated": [],
  "thread_id": null
}

Field by field, and each of these exists because of a specific failure:

  • answer — prose. Null when the system declined.
  • refusal — why it declined, in words. Not an error; do not retry it. Surface it.
  • clarification — it needs one fact from you. Ask the user, do not guess.
  • plan.calls — which tools ran. Log it; it is how you debug a bad answer.
  • trace[].data — the structured results the prose was written from. If you want your own model to reason, feed it this, not answer.
  • disclosures — caveats that change how the number should be read. Propagate these. Dropping them is how a caveated number becomes a confident wrong one downstream.
  • mutated — which tools changed something. On a key, always empty.

Handling refusals properly

res = ask("Did the price change drive up AOV?")
if res["refusal"]:
    log.info("declined: %s", res["refusal"])
    return None          # NOT a retry, NOT an exception
if res["clarification"]:
    return need_input(res["clarification"])
use(res["answer"], res["disclosures"])

A refusal is the system refusing to answer from data that cannot support the claim. Retrying it produces the same refusal and burns your rate limit; treating it as a failure hides a correct answer.


Pattern 2 — A supervised operator

To let a model change things, you need a signed-in session, and the workspace must have switched on assistant-run changes — off by default, and an owner has to turn it on in the dashboard. The assistant cannot enable it for itself.

When a turn would change something, it does not. It returns a plan:

{
  "answer": "",
  "pending_confirmation": {
    "actions": [
      {
        "tool": "classify_account",
        "arguments": { "domain": "acme.com", "classification": "competitor" },
        "effect": "Mark acme.com as a competitor...",
        "irreversible": false
      }
    ],
    "irreversible": false,
    "approve_with": { "...": "..." }
  },
  "mutated": []
}

mutated: [] and a non-null pending_confirmation together mean nothing has happened yet. Show effect to a human — it is written for them, not for you — and submit approve_with only if they agree.

Do not build an agent that auto-approves its own plans. The confirmation gate is the entire safety model for writes; an agent that approves itself has removed it and kept the ceremony.


What it can and cannot do, and how to find out

Ask it:

ask("what can you do?")

The answer is generated from the live tool registry rather than a written list, so it cannot drift from the truth. For the machine-readable version:

curl -s https://app.signal.geysera.com/agent-api/capabilities

Unauthenticated, and it publishes the auth model, scopes, rate limits, the error vocabulary, pagination bounds and the read-tool catalogue — all derived from the code. Bootstrap from it rather than from anything written down, including this page.


Addressing things by name, and why that is not a style choice

Tools take names, not ids: a rule by its name, a webhook by its URL, an account by its domain, a remembered fact by its key.

This is load-bearing. A plan's calls run concurrently with static arguments, so no call can read another's output. A tool taking an opaque id would be unreachable from a sentence — the planner cannot list things and use an id from that list in the same turn. Ask for "the rule called Hot Leads", never "rule 7f3a".

Where a name is ambiguous, resolution refuses and lists what exists rather than guessing. That refusal is correct behaviour; surface it.


It remembers things, and you should check what

The assistant keeps short facts about your workspace and feeds them into later turns, so a wrong one shapes answers you have not asked yet.

ask("what have you remembered about us?")

On a real workspace this surfaced two domains someone had told it to exclude from reports — invisible until asked, and quietly shaping every answer since. Put this in your monthly review. To remove one: "forget what you know about X".


Rate limits and cost

300 requests per minute per key. A copilot turn is seconds to tens of seconds — it is doing real work, sometimes a Bayesian time series — so the practical limit is wall-clock, not the quota. On 429 the Retry-After header is the real remaining window, not a constant; honour it.


The shape of a safe agent

  1. Read-only key for anything unattended.
  2. Log plan.calls and trace for every turn. When an answer is wrong, this is the only way to find out why.
  3. Propagate disclosures everywhere the number goes.
  4. Treat refusal as an answer.
  5. Never auto-approve pending_confirmation.
  6. Check what it remembers, monthly.
  7. Bootstrap from /capabilities, not from documentation.

Next: ask questions in natural language · watch your own data quality

Markdown source: /developers/playbooks/let-an-llm-run-your-workspace.md