YouOS — Agent operations playbook
Audience: LLM-driven agents (Hermes, OpenClaw, a Telegram bot, Claude, a custom orchestrator) operating YouOS at runtime via the REST API.
If you're a human setting up the integration, read docs/INTEGRATIONS.md first. This doc assumes the wiring already works and focuses on the runtime contract: when to call what, how to handle edge cases, what NOT to do.
1. First contact
Before the first user-facing call, do this once per session:
-
Probe the surface:
GET /openapi.jsonRe-fetch on schema mismatches. The spec is the source of truth — endpoint shapes can change between YouOS versions but the OpenAPI doc tracks reality. -
Resolve the user's account:
GET /api/agent/digestIf 200 with a non-nullaccountfield, that's the default. If 400 ("no account configured"), the user hasuser.emailsempty — ask the user which mailbox to operate on and pass?account=...on every subsequent call. -
Note the
triage_url: useful for "open the UI" hand-offs ("if you want to see the full queue, here's the link"). -
Cache the account + token + base URL for this session. Don't re-resolve on every call.
2. Decision tree
| User intent | Endpoint | Follow-up |
|---|---|---|
| "Anything important?" / "What's in my inbox" | GET /api/agent/digest |
Paraphrase summary; if pending_count > 0, list top 3 from pending_preview |
| "Push the X to Gmail" | GET /api/agent/resolve?q=X → POST /api/agent/pending/<id>/push_to_gmail |
If count == 1, push. If count > 1, disambiguate (§5). If count == 0, suggest /inbox. Creates a Gmail Draft — does NOT send |
| "Redraft X / make it shorter / change the tone" | GET /api/agent/resolve?q=X → POST /api/agent/pending/<id>/regenerate {instruction, tone_hint?, mode?, persist?} |
Re-runs generation with steering. persist:false = preview only (don't overwrite the stored draft); response carries draft + model_used |
| "Send the X" / "send it for real" | GET /api/agent/resolve?q=X → POST /api/agent/pending/<id>/confirm_send {amended_draft?} |
Hard-gated — returns 403 unless the user has enabled agent.send.enabled and the kill-switch is off. If 403, fall back to push_to_gmail and tell the user to finish-and-send from Gmail. See §7 |
| "Dismiss the X" / "Skip the X" | GET /api/agent/resolve?q=X → POST /api/agent/pending/<id>/dismiss {reason} |
Same disambiguation. Default reason: "noise" unless user qualifies |
| "Save my version as a training pair" | POST /api/agent/pending/<id>/save_as_feedback_pair {edited_reply} |
The user's correction goes in edited_reply |
| "What did the agent do today/this week" | GET /api/agent/sweeps?limit=10 |
Render sweep timestamps + counts |
| "How's the filter doing" | GET /api/agent/observability |
Read hints — the agent has rule-based interpretations baked in |
| "Run triage now" | POST /api/agent/triage |
Synchronous; can take 30-60s per account |
| "Stop drafting for sender X" | POST /api/agent/skip_senders/promote {senders: ["X"]} |
Already-present senders go in already_present, no error |
| "Find the X email" | GET /api/agent/resolve?q=X |
Return the row list with ids; don't act |
Don't substitute one for another. /resolve is read-only; /dismiss and /push_to_gmail are write actions. Calling /dismiss when the user asked "find" is a betrayal.
3. Idempotency
| Endpoint | Idempotent? | Why |
|---|---|---|
GET /api/agent/digest |
Yes | Read-only |
GET /api/agent/pending |
Yes | Read-only |
GET /api/agent/sweeps |
Yes | Read-only |
GET /api/agent/observability |
Yes | Read-only |
GET /api/agent/resolve |
Yes | Read-only |
POST /api/agent/pending/{id}/dismiss |
Yes | mark_dismissed is upsert-style; second call just re-writes the same status |
POST /api/agent/pending/{id}/mark_sent |
Yes | Same — sets timestamp again, harmless |
POST /api/agent/pending/{id}/amend |
Yes | Overwrites amended_draft |
POST /api/agent/pending/{id}/regenerate |
NO | Generates a fresh draft each call (different text). With persist:false it has no side effect (preview only) and is safe to repeat |
POST /api/agent/pending/{id}/push_to_gmail |
NO (guarded) | Creates a new Gmail draft each call. An atomic claim makes a re-push of an already-pushed row return the existing gmail_draft_id with pushed_already:true rather than duplicating — but a retry after a timeout can still duplicate; use the retry check below |
POST /api/agent/pending/{id}/send / confirm_send |
NO | Sends mail. Never auto-retry a send whose outcome you don't know — check the row's send state first |
POST /api/agent/pending/{id}/save_as_feedback_pair |
NO | Inserts a new feedback_pairs row each call |
POST /api/agent/triage |
NO | Each call runs a fresh sweep against Gmail. Rate-limited: a sweep within agent.triage_min_interval_seconds (default 60) of the last one returns 429 + Retry-After (see §4) |
POST /api/agent/skip_senders/promote |
Effectively yes | Already-present senders are silently de-duped |
On push_to_gmail retry: if the first call timed out, check GET /api/agent/pending/{id} — if gmail_draft_id is set, the push succeeded despite the timeout. Don't retry. If it's still NULL, retry once.
4. HTTP error handling
| Code | Meaning | User-facing message |
|---|---|---|
| 200 | Success | (parse the body) |
| 400 | Bad request — usually missing/invalid param | Surface the response body's detail field verbatim |
| 401 | Token missing or invalid | "Your YouOS API token has expired or was rotated. Mint a new one with youos token-create and update the bot config." |
| 403 | Forbidden — sending disabled, kill-switch on, or origin not allowed | "Sending is turned off (YouOS is draft-only by default). I pushed a Gmail draft instead — open Gmail to send." Don't retry; this is a deliberate gate |
| 404 | Row doesn't exist | "I couldn't find row #N — try /inbox to see what's currently pending" |
| 409 | Conflict — e.g. amending a row already pushed | Surface the detail; re-read the row with GET /api/agent/pending/{id} before retrying |
| 422 | Pydantic validation | Surface the response body. Common: empty edited_reply, missing q, negative offset |
| 429 | Too many sweeps — triage requested within the cooldown | Read the Retry-After header (seconds). "Last sweep was just now; I'll re-check in N s." Do NOT loop — honor Retry-After |
| 501 | Backend not implemented | "Native Gmail backend needs the gmail.compose OAuth scope. Run youos setup to re-authorize, or switch to gog/gws." |
| 502 | Backend error during Gmail write | "Gmail write failed: gog auth list." |
| 504 / timeout | Long-running call (usually /triage) |
"Triage is still running; try /inbox in 30s." |
| 5xx (other) | Server-side bug | "YouOS hit an internal error. Tell the user to check /tmp/youos-serve.log on the Mac." |
Never silently swallow a 5xx. The user needs to know something is wrong so they can fix it.
5. Disambiguation pattern
When GET /api/agent/resolve returns count > 1:
-
Do not pick automatically. Even if one row has a much higher
match_score, the user's intent is ambiguous. -
Present the rows compactly with row ids — the row id is the action handle.
-
Ask which one in a single short bubble:
Found two matches: #12 (Q3 pricing from Alice, today) and #15 (Q3 budget revision from Bob, yesterday). Which one?
-
When the user replies with a row id ("the alice one" → "#12" or "alice's"), use
q=aliceto re-resolve if you don't have a row id directly.
Do NOT chain /resolve calls to filter further client-side. The substring matcher is shallow on purpose — if the first pass returned 5 matches, asking the user is cheaper than guessing.
6. Paraphrasing the digest
The summary field looks like:
YouOS (today): 4 pending · 0 pushed · 0 dismissed (11 sweeps)
Don't restate verbatim if the user asked a focused question. Instead:
| User asked | Paraphrase |
|---|---|
| "Anything important?" | "Four emails to look at. Top: Q3 pricing from Alice." |
| "How's my inbox?" | "Mostly quiet — 4 pending, agent's swept 11 times today." |
| "What's pending?" | (list pending_preview rows, skip the headline) |
| "Did the agent draft anything today?" | "Yes — 4 drafts ready. Want to push any to Gmail?" |
| "Anything new since this morning?" | (re-issue /digest?days=1; compare to previous if you cached one) |
Empty queue: - "Inbox is clear — nothing waiting." (preferred) - NOT: "0 pending, 0 pushed, 0 dismissed across 0 sweeps." (mechanical)
Long lists (pending_preview has 5 rows):
- Show top 3 with subject + sender; offer to list the rest.
- Compact format: #13 [draft 0.70] Q3 pricing ← alice@partner.com
7. Trust boundaries
What YouOS will never let you do:
- Send mail by default.
POST .../sendandPOST .../confirm_sendexist, but they are hard-gated OFF: every send requiresagent.send.enabled: trueANDagent.outbound_kill_switch: false, both of which default to draft-only. With the defaults, those endpoints return 403 and the only outbound action available to you ispush_to_gmail(writes a Gmail Draft; the user finishes-and-sends). Never claim "I sent it" off the back of apush_to_gmail— say "I pushed the draft to Gmail; open Gmail to send." Only treat a send as done after a2xxfromsend/confirm_send. (The flags being on is the user's explicit, standing authorization; seeAGENT_SAFETY_MODEL.md.) - Read raw mail content outside the queue. You can read what's already in
agent_pending_drafts(subject + body of inbounds the agent processed). You cannot fetch arbitrary Gmail threads via this API — by design, YouOS exposes its triaged judgment, not raw mailbox access. To pull in new mail, trigger a sweep (POST /api/agent/triage, rate-limited) and read the resulting queue. - Modify identity / accounts. Adding accounts is a human-driven
gog auth login+user.emailsconfig edit. - Disable safety guards.
agent.strict_local/agent.daily_draft_cap/agent.skip_sendersare user-set; the agent can read them via/api/config/flagsbut should not change them on the user's behalf without explicit confirmation.
What the agent SHOULD NOT do without explicit confirmation:
- Call
mark_sentunless the user said "I sent that" (not "push it").mark_sentis the "I sent outside YouOS" signal — calling it after apush_to_gmailwould be wrong (the row is already in status='sent' from the push). - Call
push_to_gmailon asurfacetier row. Surface rows have no draft. The endpoint returns 400 but the right behavior is to say "that row was just surfaced for review — there's no draft to push." - Auto-promote senders to skip_senders unless explicitly asked, even if you see they have 3+ noise dismissals. The user has
agent.auto_promote_skip_sendersfor that. - Re-trigger triage in a tight loop. One call ~ one fetch+draft cycle (30-60s, uses gog auth + model). The server now enforces this: a sweep within
agent.triage_min_interval_seconds(default 60) of the last one returns 429 +Retry-After. Honor the header rather than retrying blindly, and tell the user "the last sweep was N seconds ago — try again at HH:MM."
8. Per-action side effects (DB + Gmail)
| Action | agent_pending_drafts change |
feedback_pairs change |
Gmail change |
|---|---|---|---|
dismiss |
status → dismissed, dismissal_reason set, dismissed_at set |
— | — |
mark_sent |
status → sent, sent_at set |
— | — |
amend |
status → amended, amended_draft set |
— | — |
push_to_gmail |
status → sent, gmail_draft_id set, sent_at set |
— | New draft created in Gmail Drafts folder on the original thread |
save_as_feedback_pair |
(row stays untouched — it can also be pushed/dismissed separately) | New row inserted | — |
skip_senders/promote |
(next sweep will hard-skip these senders) | — | — |
triage |
New rows inserted for messages above threshold | — | — |
Background scheduler tick (every agent.interval_minutes) |
Same as triage per account |
— | — |
Knowing the side effects lets you give honest confirmations: "Done — Gmail draft r1234 created, ready in your Drafts folder. The triage queue is updated."
9. Multi-account
If the user has multiple accounts (user.emails), every endpoint takes ?account=.... If you don't pass one, you get user.emails[0] — usually fine, but:
- For "anything in my work mail" the user means a specific account. Ask if ambiguous.
- For "agent digest" without qualifier, the headline
accountfield in the response tells you which mailbox you got. If the user wanted the other one, re-fetch with explicit?account=. - For
/api/agent/triage(manual trigger), always passaccount=explicitly. The default account on a 2-account user is too ambiguous for a write action.
10. Conversational patterns to follow / avoid
Follow
- Confirm before destructive actions: "Dismiss row #14 (EON Agent — Morning Light) as
wrong_content? (yes/no)" — but only if the action is being inferred from a description. Direct/dismiss 14 wrong_contentdoesn't need confirmation. - Surface the action handle in confirmations: "Pushed row #13 to Gmail (draft id
r1234)." — lets the user verify on Gmail's side. - Quote what changed: "Added
newsletter@daily.comto skip_senders. They won't be drafted again." - Mention the next sweep timing when relevant: "Effective on the next sweep (in ~14 minutes)."
Avoid
- Speculation about content: don't paraphrase the body of an email back at the user. The user will recognize what's in their inbox by subject + sender — they don't need you to summarize body content they didn't ask about.
- Defensive language: "I think this might be what you mean, possibly..." — just propose the match and act, or ask.
- Restating the user's instruction: "You asked me to push row #13" — wastes a bubble.
- Reporting zero counts: "Dismissed 0 errors, 0 warnings." — irrelevant.
11. When the user wants to learn the agent
These are advanced flows. The agent (you) should generally not initiate them — the user usually drives them:
- Save as training pair (
/save_as_feedback_pair): the user has a better version of the agent's draft. Take their reply text and POST it. The next nightly LoRA retrain consumes the row. - Adjust standing instructions (
/api/config/setwithagent.standing_instructions): user is going OOO or wants different tone for a week. Write to the flag. Tell the user: "Set. Next sweep will use this in every draft prompt." - Set a threshold (
/api/config/setwithagent.threshold): user is getting too many false positives (or missing real mail). 0.6 default; up to ~0.75 for stricter, down to 0.5 for looser. Encourage feedback-driven tuning (dismissals +noisereason) over manual threshold tweaks. - Adjust the draft-abstain floor (
/api/config/setwithgeneration.abstain.min_quality): on the autonomous sweep a draft scoring below this quality floor is withheld and the email is surfaced for review with no draft. 0.5 default. Lower it (e.g. 0.25) when the user reports threads surfaced with no draft that they'd have replied to — it offers more mediocre-but-editable drafts (higher recall) while still withholding empty/degenerate (~0.1) ones. Raise it to be more conservative. Interactive/draftalways drafts regardless; this never affects the never-send path.
Advanced (YAML / infra):
snapshots.keep_daily(default 30) bounds retained daily DB snapshots undervar/snapshots/daily/. On a tight disk, lower it (e.g. 10) — the nightly'sprune_snapshotsenforces it. Not a/settingstoggle; set inyouos_config.yamlor via the config file directly.
12. Versioning + capability discovery
This doc reflects the surface as of YouOS b63. The contract is:
GET /openapi.jsonis the canonical surface — if this doc and the spec disagree, the spec wins.app.state.settings.version(also surfaced in/healthzand/readyz) is the running version. Compare to your cached docs version on first contact; refetch openapi if mismatched.- New endpoints land in
/api/agent/. Old endpoints don't get removed — they get adeprecated: trueflag on the OpenAPI operation.
13. Worked example — full conversation
User says: "What's in my inbox today?"
GET /api/agent/digest?days=1
→ {
"summary": "YouOS (today): 4 pending · 0 pushed · 0 dismissed (11 sweeps)",
"pending_preview": [
{"id": 13, "tier": "draft", "needs_reply_score": 0.7,
"subject": "Would you be interested in...", "sender": "Standart..."},
{"id": 14, "tier": "draft", "needs_reply_score": 0.7,
"subject": "The Science Behind Morning Light...", "sender": "EON Agent..."},
...
],
"account": "you@gmail.com",
"triage_url": "http://bbots-mac-mini:8765"
}
Agent → User: "Four drafts ready. Top ones:
#13 Random Coffee Vienna (Standart Community)
#14 Morning Light (EON Agent)
What do you want to do?"
User: "Push the coffee one"
GET /api/agent/resolve?q=coffee
→ {"count": 1, "rows": [{"id": 13, "subject": "Would you be interested in \"Random Coffee\"...", ...}]}
POST /api/agent/pending/13/push_to_gmail
→ {"ok": true, "gmail_draft_id": "r1234567890", "row": {"status": "sent", ...}}
Agent → User: "Pushed #13 to Gmail (draft `r1234567890`). Open Gmail to send."
User: "Dismiss the morning light one as wrong content"
GET /api/agent/resolve?q=morning light
→ {"count": 1, "rows": [{"id": 14, "subject": "The Science Behind Morning Light..."}]}
POST /api/agent/pending/14/dismiss {"reason": "wrong_content"}
→ {"ok": true, "row": {"status": "dismissed", "dismissal_reason": "wrong_content", ...}}
Agent → User: "Dismissed #14 as wrong_content. The agent will use this as a training signal in tonight's LoRA retrain."
User: "How's my filter doing?"
GET /api/agent/observability
→ {
"sweep": {"sweeps": 11, "successful": 11, ...},
"dismissals": {"by_reason": {"wrong_content": 1, ...}},
"score_histogram": {...},
"hints": []
}
Agent → User: "11 sweeps today, all clean. Just one dismissal so far (the one you just did). Filter looks healthy."
This is the steady-state shape. Each user turn → 1–2 YouOS calls → 1 chat bubble back. Nothing else.
14. See also
docs/INTEGRATIONS.md— architecture + setup recipe + reference Telegram botdocs/REMOTE_ACCESS.md— Tailscale, PIN, multi-account, Gmail-label dismissalsdocs/USAGE.md— CLI command referencedocs/ARCHITECTURE.md— internal architecture of the agent loopSKILL.md— OpenClaw skill manifest (trigger phrases, install)GET /openapi.json— canonical surface contractGET /docs— Swagger UI