From 013beab861c9c326d794b2136b218df09eab326a Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Tue, 9 Jun 2026 14:27:53 +0900 Subject: [PATCH] Add Codex and Claude document draft integration --- integrations/claude/skills/odysseus/SKILL.md | 1 + .../skills/odysseus/scripts/odysseus_api.py | 32 +++++++++ integrations/codex/scripts/odysseus_api.py | 32 +++++++++ integrations/codex/skills/odysseus/SKILL.md | 1 + routes/api_token_routes.py | 1 + routes/codex_routes.py | 66 ++++++++++++++++++- 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/integrations/claude/skills/odysseus/SKILL.md b/integrations/claude/skills/odysseus/SKILL.md index d3b55b3dd..31b40ee01 100644 --- a/integrations/claude/skills/odysseus/SKILL.md +++ b/integrations/claude/skills/odysseus/SKILL.md @@ -102,6 +102,7 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory ## Email draft + send +- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. - `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`). - `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction. diff --git a/integrations/claude/skills/odysseus/scripts/odysseus_api.py b/integrations/claude/skills/odysseus/scripts/odysseus_api.py index fcef8a777..8a22eb494 100755 --- a/integrations/claude/skills/odysseus/scripts/odysseus_api.py +++ b/integrations/claude/skills/odysseus/scripts/odysseus_api.py @@ -17,6 +17,11 @@ def _usage() -> int: print(" odysseus_api.py todos add TITLE", file=sys.stderr) print(" odysseus_api.py emails list [limit]", file=sys.stderr) print(" odysseus_api.py emails read UID", file=sys.stderr) + print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr) + print(" odysseus_api.py documents list [limit]", file=sys.stderr) + print(" odysseus_api.py documents read DOC_ID", file=sys.stderr) + print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr) + print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr) print(" odysseus_api.py cookbook tasks", file=sys.stderr) print(" odysseus_api.py cookbook servers", file=sys.stderr) print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr) @@ -79,6 +84,33 @@ def main() -> int: method = "GET" path = f"/api/codex/emails/{sys.argv[3]}" body = None + elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4: + method = "POST" + path = "/api/codex/emails/draft-document" + body = " ".join(sys.argv[3:]) + else: + return _usage() + elif command in ("documents", "docs"): + if len(sys.argv) < 3: + return _usage() + action = sys.argv[2].lower() + if action == "list": + method = "GET" + limit = sys.argv[3] if len(sys.argv) >= 4 else "50" + path = f"/api/codex/documents?limit={limit}" + body = None + elif action == "read" and len(sys.argv) >= 4: + method = "GET" + path = f"/api/codex/documents/{sys.argv[3]}" + body = None + elif action == "create" and len(sys.argv) >= 4: + method = "POST" + path = "/api/codex/documents" + body = " ".join(sys.argv[3:]) + elif action == "delete" and len(sys.argv) >= 4: + method = "DELETE" + path = f"/api/codex/documents/{sys.argv[3]}" + body = None else: return _usage() elif command == "cookbook": diff --git a/integrations/codex/scripts/odysseus_api.py b/integrations/codex/scripts/odysseus_api.py index fcef8a777..8a22eb494 100755 --- a/integrations/codex/scripts/odysseus_api.py +++ b/integrations/codex/scripts/odysseus_api.py @@ -17,6 +17,11 @@ def _usage() -> int: print(" odysseus_api.py todos add TITLE", file=sys.stderr) print(" odysseus_api.py emails list [limit]", file=sys.stderr) print(" odysseus_api.py emails read UID", file=sys.stderr) + print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr) + print(" odysseus_api.py documents list [limit]", file=sys.stderr) + print(" odysseus_api.py documents read DOC_ID", file=sys.stderr) + print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr) + print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr) print(" odysseus_api.py cookbook tasks", file=sys.stderr) print(" odysseus_api.py cookbook servers", file=sys.stderr) print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr) @@ -79,6 +84,33 @@ def main() -> int: method = "GET" path = f"/api/codex/emails/{sys.argv[3]}" body = None + elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4: + method = "POST" + path = "/api/codex/emails/draft-document" + body = " ".join(sys.argv[3:]) + else: + return _usage() + elif command in ("documents", "docs"): + if len(sys.argv) < 3: + return _usage() + action = sys.argv[2].lower() + if action == "list": + method = "GET" + limit = sys.argv[3] if len(sys.argv) >= 4 else "50" + path = f"/api/codex/documents?limit={limit}" + body = None + elif action == "read" and len(sys.argv) >= 4: + method = "GET" + path = f"/api/codex/documents/{sys.argv[3]}" + body = None + elif action == "create" and len(sys.argv) >= 4: + method = "POST" + path = "/api/codex/documents" + body = " ".join(sys.argv[3:]) + elif action == "delete" and len(sys.argv) >= 4: + method = "DELETE" + path = f"/api/codex/documents/{sys.argv[3]}" + body = None else: return _usage() elif command == "cookbook": diff --git a/integrations/codex/skills/odysseus/SKILL.md b/integrations/codex/skills/odysseus/SKILL.md index 4cff1402e..d4cbdf726 100644 --- a/integrations/codex/skills/odysseus/SKILL.md +++ b/integrations/codex/skills/odysseus/SKILL.md @@ -102,6 +102,7 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex ## Email draft + send +- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send. - `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`). - `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction. diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py index 05806e420..3057ccbea 100644 --- a/routes/api_token_routes.py +++ b/routes/api_token_routes.py @@ -31,6 +31,7 @@ ALLOWED_SCOPES = { TOKEN_PROFILES = { "chat": ["chat"], "codex_todos": ["todos:read", "todos:write"], + "codex_documents": ["documents:read", "documents:write"], "codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"], } diff --git a/routes/codex_routes.py b/routes/codex_routes.py index 1afac02b9..8659b7d36 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -75,6 +75,20 @@ def _scope_owner(request: Request, allowed: set[str]) -> str: return require_user(request) +def _scope_owner_all(request: Request, required: set[str]) -> str: + """Return owner only when an API token has every required scope.""" + if getattr(request.state, "api_token", False): + scopes = set(getattr(request.state, "api_token_scopes", []) or []) + missing = required - scopes + if missing: + raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}") + owner = getattr(request.state, "api_token_owner", None) + if not owner: + raise HTTPException(403, "API token has no owner") + return owner + return require_user(request) + + def _find_endpoint(router: APIRouter | None, method: str, path: str): if router is None: return None @@ -122,7 +136,7 @@ def setup_codex_routes( "read": scoped(EMAIL_READ_SCOPES), "draft": scoped(EMAIL_DRAFT_SCOPES), "send": scoped(EMAIL_SEND_SCOPES), - "actions": ["list", "read", "draft", "send"], + "actions": ["list", "read", "draft_document", "draft", "send"], }, "memory": { "read": scoped(MEMORY_READ_SCOPES), @@ -246,6 +260,56 @@ def setup_codex_routes( # Both handlers in routes/email_routes.py already accept `owner=` via # FastAPI Depends, so we call them directly without patching state. + def _email_draft_document_content(body: dict[str, Any]) -> str: + def clean(v: Any) -> str: + if isinstance(v, list): + return ", ".join(str(x).strip() for x in v if str(x).strip()) + return str(v or "").strip() + + to = clean(body.get("to")) + cc = clean(body.get("cc")) + bcc = clean(body.get("bcc")) + subject = clean(body.get("subject")) + in_reply_to = clean(body.get("in_reply_to")) + references = clean(body.get("references")) + body_text = str(body.get("body") or body.get("body_html") or "").strip() + lines = [ + f"To: {to}", + ] + if cc: + lines.append(f"Cc: {cc}") + if bcc: + lines.append(f"Bcc: {bcc}") + lines.append(f"Subject: {subject}") + if in_reply_to: + lines.append(f"In-Reply-To: {in_reply_to}") + if references: + lines.append(f"References: {references}") + lines.extend(["---", body_text]) + return "\n".join(lines).rstrip() + "\n" + + @router.post("/emails/draft-document") + async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)): + owner = _scope_owner_all(request, {"email:draft", "documents:write"}) + if documents_create_endpoint is None: + raise HTTPException(503, "Documents integration is not available") + from routes.document_routes import DocumentCreate + + subject = str(body.get("subject") or "Email draft").strip() or "Email draft" + title = str(body.get("title") or subject).strip() or "Email draft" + req = DocumentCreate( + session_id=body.get("session_id"), + title=title, + language="email", + content=_email_draft_document_content(body), + ) + result = await _as_owner(request, owner, documents_create_endpoint, request, req) + if isinstance(result, dict): + result = dict(result) + result["draft_type"] = "document" + result["send_required_confirmation"] = True + return result + @router.post("/emails/draft") async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)): owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)