{
  "schemaVersion": "1.0",
  "item": {
    "slug": "officex",
    "name": "OfficeX",
    "source": "tencent",
    "type": "skill",
    "category": "效率提升",
    "sourceUrl": "https://clawhub.ai/mevdragon/officex",
    "canonicalUrl": "https://clawhub.ai/mevdragon/officex",
    "targetPlatform": "OpenClaw"
  },
  "install": {
    "downloadMode": "redirect",
    "downloadUrl": "/downloads/officex",
    "sourceDownloadUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=officex",
    "sourcePlatform": "tencent",
    "targetPlatform": "OpenClaw",
    "installMethod": "Manual import",
    "extraction": "Extract archive",
    "prerequisites": [
      "OpenClaw"
    ],
    "packageFormat": "ZIP package",
    "includedAssets": [
      "SKILL.md"
    ],
    "primaryDoc": "SKILL.md",
    "quickSetup": [
      "Download the package from Yavira.",
      "Extract the archive and review SKILL.md first.",
      "Import or place the package into your OpenClaw setup."
    ],
    "agentAssist": {
      "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
      "steps": [
        "Download the package from Yavira.",
        "Extract it into a folder your agent can access.",
        "Paste one of the prompts below and point your agent at the extracted folder."
      ],
      "prompts": [
        {
          "label": "New install",
          "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
        },
        {
          "label": "Upgrade existing",
          "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
        }
      ]
    },
    "sourceHealth": {
      "source": "tencent",
      "status": "healthy",
      "reason": "direct_download_ok",
      "recommendedAction": "download",
      "checkedAt": "2026-04-23T16:43:11.935Z",
      "expiresAt": "2026-04-30T16:43:11.935Z",
      "httpStatus": 200,
      "finalUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=4claw-imageboard",
      "contentType": "application/zip",
      "probeMethod": "head",
      "details": {
        "probeUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=4claw-imageboard",
        "contentDisposition": "attachment; filename=\"4claw-imageboard-1.0.1.zip\"",
        "redirectLocation": null,
        "bodySnippet": null
      },
      "scope": "source",
      "summary": "Source download looks usable.",
      "detail": "Yavira can redirect you to the upstream package for this source.",
      "primaryActionLabel": "Download for OpenClaw",
      "primaryActionHref": "/downloads/officex"
    },
    "validation": {
      "installChecklist": [
        "Use the Yavira download entry.",
        "Review SKILL.md after the package is downloaded.",
        "Confirm the extracted package contains the expected setup assets."
      ],
      "postInstallChecks": [
        "Confirm the extracted package includes the expected docs or setup files.",
        "Validate the skill or prompts are available in your target agent workspace.",
        "Capture any manual follow-up steps the agent could not complete."
      ]
    },
    "downloadPageUrl": "https://openagent3.xyz/downloads/officex",
    "agentPageUrl": "https://openagent3.xyz/skills/officex/agent",
    "manifestUrl": "https://openagent3.xyz/skills/officex/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/officex/agent.md"
  },
  "agentAssist": {
    "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
    "steps": [
      "Download the package from Yavira.",
      "Extract it into a folder your agent can access.",
      "Paste one of the prompts below and point your agent at the extracted folder."
    ],
    "prompts": [
      {
        "label": "New install",
        "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
      },
      {
        "label": "Upgrade existing",
        "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
      }
    ]
  },
  "documentation": {
    "source": "clawhub",
    "primaryDoc": "SKILL.md",
    "sections": [
      {
        "title": "OfficeX Platform",
        "body": "OfficeX is a membership-based app store. Users buy credits ($0.03 each: $0.02 profit + $0.01 ecosystem liability). Apps charge credits via reserve/settle. Vendors earn credits and payout to fiat (USDC on Solana or bank transfer, $0.01/credit).\n\nGet your credentials at: https://officex.app/store/en/developer/"
      },
      {
        "title": "Environments",
        "body": "EnvAPI BaseChat StreamStaging (default)https://staging-backend.cloud.officex.app/v1https://chat-staging.cloud.officex.app/Productionhttps://cloud.officex.app/v1https://chat.cloud.officex.app/"
      },
      {
        "title": "Authentication",
        "body": "ModeHeadersScopeNone—Public catalog, vouchers, auth endpointsMaster Keyx-officex-user-id + x-officex-master-keyProfile, installs, wallets, vendor appsInstall Secretx-officex-install-id + x-officex-install-secretBilling: reserve, settle, cancel, inboxInstall Secret (alt)x-officex-user-id + x-officex-app-id + x-officex-install-secretSame as above (alternative lookup)Superadminx-officex-admin-secretFull system (/admin/*)\n\nInstall Secret is billing only — your app manages its own user authentication separately. The install secret handles the money, your app handles everything else."
      },
      {
        "title": "Credit Economy",
        "body": "User buys credits → Treasury liability increases (zero-sum: Treasury + all wallets = 0)\nApp reserves credits → Locked from user wallet\nApp sips/settles → Credits move to app wallet\nVendor payouts → Credits converted to fiat, Treasury liability decreases"
      },
      {
        "title": "Decimal Credits",
        "body": "Credits support decimals. Rounding up to 1 credit ($0.03) is expensive for small operations:\n\nOperationCreditsUser PaysMicro task0.1$0.003Small task0.25$0.0075Medium task0.5$0.015Standard1.0$0.03\n\nBest practice: Price based on actual cost. If an API call costs $0.001, charge ~0.07 credits (2x markup)."
      },
      {
        "title": "Dual Roles",
        "body": "A single OfficeX account can be both Consumer (install and use apps) and Vendor (create apps and earn credits)."
      },
      {
        "title": "Auth (No Auth)",
        "body": "POST /auth/register              { email }                     → { success, message }\nPOST /auth/login                 { email, password? }          → { success, api_key?, user_id? }\nPOST /auth/verify-otp            { email, code }               → { success, api_key, user_id, wallet_id? }\nPOST /auth/resend                { email }                     → { success, message }\nPOST /auth/forgot-password       { email }                     → { success, message }\nPOST /auth/reset-password        { email, code, new_password } → { success, message }\nPOST /auth/set-password          { password }            [MK]  → { success, message }\nPOST /auth/rotate-key            { email }                     → { success, message }\nPOST /auth/confirm-rotate-key    { email, code }               → { success, api_key }\nPOST /register-user              { email }                     → { user_id, wallet_id, api_key }  (legacy)\n\nTesting mode: OTP hardcoded to 0000, email sending disabled."
      },
      {
        "title": "User Profile [Master Key]",
        "body": "GET    /users/me                                → { user: { user_id, email, wallet_id, status } }\nPATCH  /users/me                 { email? }     → { user }\nPOST   /users/me/rotate-key     { new_master_key } → { success }\nGET    /users/me/vouchers                       → { vouchers[] }"
      },
      {
        "title": "Installations [Master Key]",
        "body": "GET    /users/me/installs                       → { installs[] }\nGET    /users/me/installs/{id}                  → { install (with usage stats) }\nPOST   /install/{app_id_or_slug} { max_per_hour?, max_per_day?, max_per_month?, allowed_until? }\n                                                → { app_id, install_id, install_secret, agent_context? }  ⚠️ secret shown once\nDELETE /users/me/installs/{id}                  → { success }\nPATCH  /users/me/installs/{id}  { max_per_hour?, max_per_day?, max_per_month?, allowed_until?, lifetime_spend_limit? }\n                                                → { install }\nPOST   /users/me/installs/{id}/rotate-secret    → { install_secret }\nPATCH  /users/me/installs/{id}/context  { key: val | null } → { agent_context }\n\nallowed_until: unix timestamp (-1 = never, default = now + 30d). lifetime_spend_limit: -1 = unlimited.\n\nApp-scoped routes (Install Secret auth):\n\nPATCH  /installs/{install_id}/context  { key: val | null } → { agent_context }\nPOST   /installs/{install_id}/inbox    { id, title, text, url?, icon? } → { message_id }"
      },
      {
        "title": "Vendor Apps [Master Key]",
        "body": "GET    /users/me/apps                           → { apps[] }\nPOST   /register-app             (see full schema below)\n                                                → { app_id, destination_wallet_id }\nGET    /users/me/apps/{app_id}                  → { app }\nPATCH  /users/me/apps/{app_id}   (see full schema below)\n                                                → { app }\nDELETE /users/me/apps/{app_id}                  → { success }\nGET    /users/me/apps/{app_id}/inbox            → { reservations[], pagination? }\nPOST   /users/me/apps/{app_id}/inbox/{log_id}/ack → { success }\nGET    /users/me/apps/{app_id}/installs         → { installs[] }"
      },
      {
        "title": "Public Catalog (No Auth)",
        "body": "GET    /apps                                    → { apps[], pagination? }\nGET    /apps/{app_id}                           → { app }"
      },
      {
        "title": "Credits & Balance [Master Key]",
        "body": "POST   /purchase-credits         { amount, payment_method: { type, token }, idempotency_key? }\n                                                → { credits_added, new_balance, transaction_id }\nGET    /balance                                 → { wallet_id, available, reserved, total }"
      },
      {
        "title": "Reserve & Settle [Install Secret]",
        "body": "POST   /reserve                  { amount, job_id, metadata? }\n                                                → { reservation_id, amount_reserved }\nPOST   /settle                   { reservation_id, amount, final? }\n                                                → { settled_amount, remaining_reserved, status }\nGET    /reservations/{id}                       → { reservation }\nPOST   /reservations/{id}/cancel                → { refunded_amount, status }\nPOST   /reservations/{id}/settle { amount, final? } → { settled_amount, status }\n\nfinal: true = complete + refund remainder. final: false (default) = sip (partial).\n\nReserve errors: INSTALL_EXPIRED, RATE_LIMITED, INSUFFICIENT_FUNDS, DUPLICATE_JOB, LIFETIME_LIMIT_REACHED"
      },
      {
        "title": "Wallets [Master Key]",
        "body": "GET    /wallets/{id}                            → { wallet_id, available, reserved, total, owner_type }\nGET    /wallets/{id}/transactions  ?limit=&cursor= → { transactions[], pagination? }\nGET    /wallets/{id}/transactions/{log_id}      → { transaction }\nGET    /wallets/{id}/reservations  ?direction=   → { reservations[] }\nGET    /wallets/{id}/reservations/{resv_id}     → { reservation }\nGET    /wallets/{id}/payouts                    → { payouts[] }\nGET    /wallets/{id}/payouts/{payout_id}        → { payout }"
      },
      {
        "title": "Payouts [Master Key]",
        "body": "POST   /payout                   { wallet_id, amount, destination: { type, account_id }, idempotency_key? }\n                                                → { payout_id, status }\n\nPayout state machine: pending → burned → completed | failed (failed = credits restored). Frequency: end of every month. Rate: $0.01 per credit."
      },
      {
        "title": "Vouchers",
        "body": "GET    /vouchers/{code}                         → { voucher }           (No Auth)\nPOST   /vouchers/{code}/redeem   { wallet_id }  → { credits_added }    [Master Key]"
      },
      {
        "title": "Chat [Master Key]",
        "body": "GET    /users/me/chats  ?project_id=&tracer_id= → { threads[] }\nPOST   /users/me/chats           { title?, project_id?, tracer_id? }     → { thread }\nGET    /users/me/chats/{id}                     → { thread, messages[] }\nPATCH  /users/me/chats/{id}      { title?, tracer_id?: string|null }      → { thread }\nDELETE /users/me/chats/{id}                     → { success }\n\nStreaming (Function URL, NOT API Gateway):\n\nPOST   <CHAT_STREAM_URL>\nHeaders: x-officex-user-id, x-officex-master-key\nBody: { messages[], thread_id?, project_id?, system_prompt?, tracer_id?, include_apps? }\nResponse: text/event-stream (SSE)\n\nStream protocol lines (emitted before SSE data):\n\nt:{threadId}\\n — resolved thread ID (always emitted)\ntracer:{tracerId}\\n — tracer ID (emitted when tracer_id present in request)\ns:{json}\\n — status updates"
      },
      {
        "title": "Inbox, Prompts, Refs, Uploads [Master Key]",
        "body": "GET    /users/me/inbox                          → { messages[] }\nGET/POST/PATCH/DELETE /users/me/prompts[/{id}]  → prompt CRUD\nGET    /refs/{slug}                             → { ref }  (No Auth)\nGET/POST /users/me/refs[/{slug}]                → ref CRUD [Master Key]\nPOST   /uploads/presign { filename, content_type } → { presigned_url, key }"
      },
      {
        "title": "Admin [Superadmin]",
        "body": "All under /admin/* with x-officex-admin-secret:\n\nUsers: GET /admin/users, GET/PATCH /admin/users/{id}, GET /admin/users/{id}/wallets\nApps: GET /admin/apps, GET/PATCH/DELETE /admin/apps/{id}\nWallets: GET /admin/wallets, GET /admin/wallets/{id}, POST /admin/wallets/{id}/adjust { amount }\nTreasury: GET /admin/treasury, POST /admin/reconcile, GET /admin/audit\nPayouts: GET /admin/payouts, GET /admin/payouts/{id}, POST /admin/payouts/{id}/approve, POST /admin/payouts/{id}/reject\nVouchers: POST /admin/vouchers, GET /admin/vouchers, GET/PATCH/DELETE /admin/vouchers/{code}"
      },
      {
        "title": "Error Format",
        "body": "{ \"success\": false, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n\nCodeHTTPDescriptionUNAUTHORIZED401Missing or invalid auth headersINVALID_SECRET401Secret doesn't match hashINVALID_REQUEST400Malformed request bodyAPP_NOT_FOUND404App doesn't existUSER_NOT_FOUND404User doesn't existWALLET_NOT_FOUND404Wallet doesn't existRESERVATION_NOT_FOUND404Reservation doesn't existINSTALL_NOT_FOUND404Installation doesn't existINSTALL_EXPIRED403Billing authorization expiredRATE_LIMITED429Exceeded rate limitINSUFFICIENT_FUNDS402Wallet balance too lowLIFETIME_LIMIT_REACHED403Cumulative spending exceeds lifetime limitFORBIDDEN403Accessing another user's resourcesDUPLICATE_REQUEST409Idempotency key collisionDUPLICATE_JOB409Job ID already has reservationPREVIOUS_FAILURE409Idempotency collision (failed, use new key)PAYMENT_FAILED402External payment rejectedPAYOUT_FAILED500Fiat transfer failedINTERNAL_ERROR500Unexpected server error"
      },
      {
        "title": "Using 3rd Party Apps",
        "body": "Whenever you use an app on OfficeX, you can grab the agent_context from the app installation. This may give you app secrets to interact with their REST API. However, not all apps might have it. Every app has their own skill.md that you can copy online or request via OfficeX API."
      },
      {
        "title": "1. Create Your App",
        "body": "Endpoint: POST /register-app [Master Key]\n\n{\n  name: string,                    // Required: 3-50 chars\n  description?: string,\n  price_type?: \"FREE\" | \"PAY_PER_USE\" | \"ONE_TIME\" | \"SUBSCRIPTION\" | \"MIXED\",\n  webhook_url?: string,            // HTTPS URL for lifecycle events\n  suggested_rate_limits?: { max_per_hour?, max_per_day?, max_per_month?, expires_at? },\n  minimum_rate_limits?: { max_per_hour?, max_per_day?, max_per_month?, expires_at? },\n  subtitle?: string,               // Max 200 chars\n  category?: string,               // Max 50 chars\n  developer?: string,\n  app_url?: string,\n  iframe_url?: string,             // URL for embedded iframe experience\n  support_url?: string,\n  contact_email?: string,\n  context_prompt?: string,         // AI agent instructions (max 5000 chars)\n  documentation?: string,          // API docs for AI agent (max 50000 chars)\n  pricing_lines?: string[],        // Max 10 items, 100 chars each\n  tags?: string[],                 // Max 10\n  icon?: { type: \"emoji\" | \"image\", content: string },\n  inAppPurchases?: boolean\n}\n// Response (201): { success, app_id, destination_wallet_id, message }\n\nExample:\n\ncurl -X POST https://cloud.officex.app/v1/register-app \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-user-id: $OFFICEX_USER_ID\" \\\n  -H \"x-officex-master-key: $OFFICEX_API_KEY\" \\\n  -d '{\n    \"name\": \"Lead Enrichment Pro\",\n    \"description\": \"Enrich B2B leads with company data\",\n    \"price_type\": \"PAY_PER_USE\",\n    \"subtitle\": \"B2B lead enrichment powered by AI\",\n    \"category\": \"Marketing\",\n    \"developer\": \"Acme Corp\",\n    \"webhook_url\": \"https://myapp.com/webhooks/officex\",\n    \"iframe_url\": \"https://myapp.com/officex\",\n    \"documentation\": \"## API Reference\\n\\nThis app enriches leads...\",\n    \"context_prompt\": \"This app enriches B2B leads. When the user asks to enrich leads, call the /enrich endpoint.\",\n    \"suggested_rate_limits\": { \"max_per_hour\": 50, \"max_per_day\": 200, \"max_per_month\": 2000 },\n    \"pricing_lines\": [\"5 credits per lead enrichment\", \"Bulk discount: 3 credits for 10+ leads\"]\n  }'\n\nEach app gets a discrete wallet (destination_wallet_id). Earnings go there, not your personal wallet. documentation is injected into the AI chat agent's system prompt. context_prompt provides additional instructions for the AI agent."
      },
      {
        "title": "2. Update Your App",
        "body": "Endpoint: PATCH /users/me/apps/{app_id} [Master Key] — All fields optional, set to null to clear:\n\n{\n  name?, description?, price_type?, webhook_url?, iframe_url?, app_url?,\n  subtitle?, category?, developer?, support_url?, contact_email?,\n  context_prompt?, documentation?, pricing_lines?, tags?,\n  icon?, previews?, icon_url?, preview_images?, youtube_url?,\n  suggested_rate_limits?, minimum_rate_limits?, inAppPurchases?\n}"
      },
      {
        "title": "3. List App's Installs (Vendor View)",
        "body": "Endpoint: GET /users/me/apps/{app_id}/installs [Master Key]\n\n// Response (200)\n{\n  success: true,\n  installs: Array<{\n    user_id: string, install_id: string, nickname?: string, status: string,\n    installed_at: string, max_per_hour: number, max_per_day: number,\n    max_per_month: number, allowed_until: number,\n    usage: { hour: number, day: number, month: number }\n  }>\n}"
      },
      {
        "title": "Installation Flow (When Users Install Your App)",
        "body": "When a user installs your app, OfficeX:\n\nCreates an Installation Record linking user to app\nGenerates an Install ID and Install Secret (scoped billing credentials)\nSets rate limits (user-specified or your suggested defaults)\nSets allowed_until expiry (default: 30 days, or -1 for no expiry)\nFires an INSTALL webhook to your webhook_url (if configured)\n\nThe install endpoint accepts both app_id (UUID) and slug (string) in the path parameter."
      },
      {
        "title": "Webhook Events",
        "body": "Your app receives lifecycle events at webhook_url. Envelope: { event, payload, uuid }.\n\nINSTALL Event:\n\n{\n  \"event\": \"INSTALL\",\n  \"payload\": {\n    \"install_id\": \"uuid-of-installation\",\n    \"install_secret\": \"base64url-encoded-secret\",\n    \"user_id\": \"uuid-of-user\",\n    \"app_id\": \"uuid-of-your-app\",\n    \"email\": \"user@example.com\",\n    \"timestamp\": \"2025-01-25T10:30:00Z\"\n  },\n  \"uuid\": \"unique-request-id\"\n}\n\nUNINSTALL Event: payload: { install_id, user_id, app_id, timestamp }\n\nRATE_LIMIT_CHANGE Event: payload: { install_id, user_id, app_id, max_per_hour, max_per_day, max_per_month, allowed_until, timestamp }\n\nWebhook Response: Your response for INSTALL can include agent_context (only this key is extracted). Values are stored on the installation and injected into the user's AI agent prompt:\n\n{\n  \"agent_context\": {\n    \"api_key\": \"sk-abc123\",\n    \"workspace_id\": \"ws-456\",\n    \"base_url\": \"https://myapp.com/api/v1\"\n  }\n}\n\nDelivery: POST, application/json, 25s timeout, fire-and-forget (no retries in v1).\n\nNote: Not all apps need a webhook. Apps where the user supplies their own credentials (e.g., Telegram bot token) can skip the webhook entirely and use PATCH /installs/{install_id}/context from within the app UI post-install. See Agent Context for details."
      },
      {
        "title": "The Reserve → Sip → Settle Pattern",
        "body": "┌─────────────┐     ┌─────────────┐     ┌─────────────┐\n│   RESERVE   │────►│     SIP     │────►│   SETTLE    │\n│  Lock funds │     │  Progressive│     │  Finalize   │\n│  for job    │     │  billing    │     │  + refund   │\n└─────────────┘     └─────────────┘     └─────────────┘\n                          │\n                          ▼ (if job fails)\n                    ┌─────────────┐\n                    │   CANCEL    │\n                    │  Full refund│\n                    └─────────────┘"
      },
      {
        "title": "Reserve (Lock Funds)",
        "body": "POST /reserve [Install Secret]\n\n// Request\n{ amount: number, job_id: string, metadata?: Record<string, unknown> }\n// Response (200)\n{ success: true, reservation_id: string, amount_reserved: number }\n\ncurl -X POST https://cloud.officex.app/v1/reserve \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-install-id: $INSTALL_ID\" \\\n  -H \"x-officex-install-secret: $INSTALL_SECRET\" \\\n  -d '{ \"amount\": 10, \"job_id\": \"lead-enrich-job-12345\", \"metadata\": { \"leads_count\": 50 } }'\n\nInternally: user's wallet.available decreases, wallet.reserved increases, creates RESV#<job_id>.\n\nError CodeMeaningINSTALL_EXPIREDallowed_until timestamp has passedLIFETIME_LIMIT_REACHEDCumulative spending exceeds limitRATE_LIMITEDHourly/daily/monthly limit exceededINSUFFICIENT_FUNDSNot enough available creditsDUPLICATE_JOBThis job_id already has a reservation"
      },
      {
        "title": "Sip (Progressive Settlement)",
        "body": "POST /reservations/{reservation_id}/settle (or POST /settle) [Install Secret]\n\n// Request (partial)\n{ amount: number, final: false }\n// Response (200)\n{ success: true, settled_amount: number, remaining_reserved: number, status: \"partial\" }\n\nExample — enriching 100 leads:\n\nReserve 100 credits\n├── Sip 10 (processed 10 leads) → settled: 10, reserved: 90\n├── Sip 10 (processed 20 leads) → settled: 20, reserved: 80\n├── Sip 10 (processed 30 leads) → settled: 30, reserved: 70\n└── Settle final 70 → settled: 100, reserved: 0, status: \"completed\""
      },
      {
        "title": "Settle (Final)",
        "body": "// Request (final)\n{ amount: number, final: true }\n// Response (200)\n{ success: true, settled_amount: number, remaining_reserved: number, status: \"completed\" }\n\nRemaining reserved funds (if any) are refunded to user. Credits move to your app's wallet."
      },
      {
        "title": "Cancel (Full Refund)",
        "body": "POST /reservations/{reservation_id}/cancel [Install Secret]\n\n// Response (200)\n{ success: true, refunded_amount: number, status: \"cancelled\" }"
      },
      {
        "title": "Rate Limits & Allowances",
        "body": "Rate limits are per (user, app) pair — each installation has independent limits.\n\nWindowDefaultDescriptionHourly100 reservationsResets each hourDaily300 reservationsResets each dayMonthly1000 reservationsResets each month\n\nMinimum rate limits (developer-set floor): Users cannot go below these values.\n\nallowed_until: Unix timestamp (-1 = never expires, default = now + 30 days). When expired, all /reserve calls fail with INSTALL_EXPIRED. Enables pseudo-subscription billing.\n\nlifetime_spend_limit: Total credits an app can ever charge across all time. Set to -1 for unlimited. Fails with LIFETIME_LIMIT_REACHED when exceeded.\n\nApp TypeSuggested HourlyDailyMonthlyLead enrichment502002000Data export1050200AI generation201001000Real-time lookup1005005000"
      },
      {
        "title": "Agent Context (AI Chat Integration)",
        "body": "When users chat with OfficeX AI, the agent receives your app's documentation and context_prompt. Store per-install credentials via:\n\nPATCH /users/me/installs/{install_id}/context [Master Key] or PATCH /installs/{install_id}/context [Install Secret]\n\n// Request: Record<string, string | null> (null deletes key)\n{ \"api_key\": \"sk-abc123\", \"workspace_id\": \"ws-456\" }\n// Response (200)\n{ success: true, agent_context: { \"api_key\": \"sk-abc123\", \"workspace_id\": \"ws-456\" } }\n\nValidation: Max 50 keys, 200 chars/key, 1000 chars/value.\n\nTwo patterns for setting agent_context:\n\nWebhook-response (auto): Return credentials in your INSTALL webhook response → auto-applied as agent_context. Best for apps that provision credentials server-side on install.\n\n\nPost-install from app UI (manual): App collects credentials from the user inside its own iframe/UI after install, then PATCHes them via install secret auth. Best for apps where the user supplies their own API key/token (e.g., Telegram bot token, OpenAI key, Stripe key). Flow:\n\nUser installs app (no extra params needed)\nUser opens app → enters their credentials in the app's UI\nApp calls PATCH /installs/{install_id}/context with install secret auth\nCredentials are stored on the installation → available to AI agent via load_app_skill\n\n# App-side context update (install secret auth)\nPATCH /v1/installs/{install_id}/context\nHeaders: X-Officex-Install-Id: {install_id}, X-Officex-Install-Secret: {install_secret}\nBody: { \"telegram_bot_token\": \"123456:ABC-DEF...\" }\n→ { success: true, agent_context: { \"telegram_bot_token\": \"123456:ABC-DEF...\" } }"
      },
      {
        "title": "Sending Inbox Messages",
        "body": "Notify users about job status, results, or important updates:\n\nPOST /installs/{install_id}/inbox [Install Secret]\n\n// Request\n{\n  id: string,          // Idempotency key (unique per app+user)\n  title: string,       // Max 200 chars\n  text: string,        // Max 2000 chars\n  url?: string,        // Link to results\n  icon?: string        // Icon URL\n}\n// Response (201) — New message\n{ success: true, message_id: string }\n// Response (200) — Deduplicated\n{ success: true, message_id: string, deduplicated: true }\n\ncurl -X POST https://cloud.officex.app/v1/installs/$INSTALL_ID/inbox \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-install-id: $INSTALL_ID\" \\\n  -H \"x-officex-install-secret: $INSTALL_SECRET\" \\\n  -d '{\n    \"id\": \"job-12345-complete\",\n    \"title\": \"Lead Enrichment Complete\",\n    \"text\": \"Successfully enriched 50 leads. 3 could not be found.\",\n    \"url\": \"https://myapp.com/results/12345\"\n  }'"
      },
      {
        "title": "Pattern 1: Free Apps",
        "body": "No reservations needed. Use inbox messages to communicate."
      },
      {
        "title": "Pattern 2: One-Time Purchase",
        "body": "Reserve and settle immediately for discrete actions:\n\nconst reservation = await reserve({ amount: 0.5, job_id: `enrich-${leadId}` });\nconst result = await enrichLead(leadId);\nawait settle({ amount: 0.5, final: true });"
      },
      {
        "title": "Pattern 3: Usage-Based (Progressive Sip)",
        "body": "For long-running jobs, bill incrementally:\n\nconst reservation = await reserve({ amount: 10, job_id: `batch-${batchId}` });\nfor (const item of items) {\n  await processItem(item);\n  await settle({ amount: 0.1, final: false }); // sip per item\n}\nawait settle({ amount: 0, final: true }); // finalize, refund unused"
      },
      {
        "title": "Pattern 4: Subscription-like",
        "body": "Use allowed_until for time-based access:\n\nif (\n  install.allowed_until !== -1 &&\n  Date.now() / 1000 >= install.allowed_until\n) {\n  return { error: \"Please renew your subscription\" };\n}"
      },
      {
        "title": "Pattern 5: Internal Credits System (Recommended)",
        "body": "Decouple your app from OfficeX API by maintaining your own internal ledger:\n\nReserve + settle OfficeX credits in bulk\nMint equivalent internal credits in your DB\nYour app logic consumes internal credits only — no OfficeX API calls during normal operation\n\nOfficeX Credits (external)          Your App Credits (internal)\n┌─────────────────────┐             ┌─────────────────────┐\n│ User's OfficeX      │  reserve    │                     │\n│ wallet              │────────────►│  (funds locked)     │\n│                     │  settle     │  Internal ledger    │\n│                     │────────────►│  += settled amount  │\n│                     │             │  App consumes from  │\n│                     │             │  internal ledger    │\n└─────────────────────┘             └─────────────────────┘\n\nasync function settleAndMintCredits(\n  reservationId,\n  installId,\n  installSecret,\n  amount,\n  final,\n  userId,\n) {\n  const result = await fetch(\n    `https://cloud.officex.app/v1/reservations/${reservationId}/settle`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": installId,\n        \"x-officex-install-secret\": installSecret,\n      },\n      body: JSON.stringify({ amount, final }),\n    },\n  ).then((r) => r.json());\n  if (!result.success) throw new Error(result.error.message);\n  await db.internalCredits.increment(userId, amount);\n  return result;\n}\n\n// Reserve a block, settle immediately, user spends internal credits freely\nconst reservation = await reserve({\n  amount: 50,\n  job_id: `session-${sessionId}`,\n});\nawait settleAndMintCredits(\n  reservation.reservation_id,\n  installId,\n  installSecret,\n  50,\n  true,\n  userId,\n);\n// Now user has 50 internal credits — no more OfficeX API calls needed\n\nBenefits: decoupled from API availability, flexible internal pricing, auditability, batch funding, simpler error handling."
      },
      {
        "title": "Frontend Integration (Iframe Embedding)",
        "body": "When users launch your app from OfficeX, it loads in an iframe with credentials as URL params:\n\nhttps://your-app.com/officex?officex_customer_id={user_id}&officex_install_id={install_id}&officex_install_secret={install_secret}"
      },
      {
        "title": "Extracting Credentials",
        "body": "JavaScript/TypeScript:\n\nconst params = new URLSearchParams(window.location.search);\nconst customerId = params.get(\"officex_customer_id\");\nconst installId = params.get(\"officex_install_id\");\nconst installSecret = params.get(\"officex_install_secret\");\n\nif (!installId || !installSecret) {\n  showError(\"Please access this app through the OfficeX app store\");\n  return;\n}\nsessionStorage.setItem(\"officex_install_id\", installId);\nsessionStorage.setItem(\"officex_install_secret\", installSecret);\n\nPython (Flask):\n\n@app.route('/officex')\ndef officex_entry():\n    install_id = request.args.get('officex_install_id')\n    install_secret = request.args.get('officex_install_secret')\n    if not install_id or not install_secret:\n        return \"Please access this app through OfficeX\", 403\n    session['officex_install_id'] = install_id\n    session['officex_install_secret'] = install_secret\n    return render_template('app.html')"
      },
      {
        "title": "Required: Allow OfficeX to Embed Your App",
        "body": "Your app must allow the OfficeX domain to embed it via iframe:\n\nContent-Security-Policy: frame-ancestors 'self' https://officex.app https://*.officex.app\n\nNext.js (next.config.js):\n\nmodule.exports = {\n  async headers() {\n    return [\n      {\n        source: \"/:path*\",\n        headers: [\n          {\n            key: \"Content-Security-Policy\",\n            value:\n              \"frame-ancestors 'self' https://officex.app https://*.officex.app\",\n          },\n        ],\n      },\n    ];\n  },\n};\n\nExpress.js:\n\napp.use((req, res, next) => {\n  res.setHeader(\n    \"Content-Security-Policy\",\n    \"frame-ancestors 'self' https://officex.app https://*.officex.app\",\n  );\n  next();\n});\n\nWithout this header: blank screen or console errors about \"refused to frame.\"\n\nSecurity:\n\nNever expose install_secret to users (use sessionStorage, not localStorage)\nValidate on backend for sensitive operations\nHTTPS required\nHandle missing params gracefully (users may bookmark deep links)"
      },
      {
        "title": "Webhook Authentication Flow (SSO)",
        "body": "The INSTALL webhook enables seamless single sign-on:\n\nUser clicks \"Install\" → OfficeX POSTs webhook → Your app creates/links user\n→ Returns agent_context → User launches iframe → Lookup by install_id → Authenticated!"
      },
      {
        "title": "Implementing onInstall Authentication",
        "body": "Step 1: Handle webhook:\n\napp.post(\"/webhooks/officex\", async (req, res) => {\n  const { event, payload, uuid } = req.body;\n  if (event === \"INSTALL\") {\n    const { install_id, install_secret, user_id, app_id, email } = payload;\n    let user = await db.users.findOne({ officex_user_id: user_id });\n    if (!user) {\n      user = await db.users.create({\n        officex_user_id: user_id,\n        officex_install_id: install_id,\n        created_at: new Date(),\n      });\n    } else {\n      await db.users.update(\n        { officex_user_id: user_id },\n        { officex_install_id: install_id },\n      );\n    }\n    // Return agent_context — credentials the AI agent needs to call your API\n    res.json({\n      agent_context: {\n        user_token: user.apiToken,\n        base_url: \"https://myapp.com/api/v1\",\n      },\n    });\n  } else {\n    res.json({ received: true });\n  }\n});\n\nStep 2: Look up user when iframe loads:\n\napp.get(\"/officex\", async (req, res) => {\n  const installId = req.query.officex_install_id;\n  const customerId = req.query.officex_customer_id;\n  const user = await resolveUser(installId, customerId);\n  req.session.user = user;\n  req.session.officex = {\n    install_id: installId,\n    install_secret: req.query.officex_install_secret,\n  };\n  res.redirect(\"/dashboard\");\n});"
      },
      {
        "title": "Handling Webhook Failures",
        "body": "Webhooks are fire-and-forget (no retries). Always have a fallback:\n\nasync function resolveUser(installId, customerId) {\n  // Try install_id first (most specific)\n  let user = await db.users.findOne({ officex_install_id: installId });\n  if (user) return user;\n  // Fall back to customer_id (they might have reinstalled)\n  user = await db.users.findOne({ officex_user_id: customerId });\n  if (user) {\n    await db.users.update({ id: user.id }, { officex_install_id: installId });\n    return user;\n  }\n  // No user found — create on-the-fly\n  return await db.users.create({\n    officex_user_id: customerId,\n    officex_install_id: installId,\n    created_at: new Date(),\n  });\n}"
      },
      {
        "title": "Fault Tolerance",
        "body": "Your app should treat OfficeX as an external payment layer, not a core dependency.\n\nPrinciples:\n\nWrap all OfficeX API calls in try/catch with timeouts (3-5 seconds)\nYour app should function if all OfficeX code was removed — OfficeX is how you get paid, not your runtime\nUse the Internal Credits pattern (Pattern 5) for maximum resilience\n\nResilient billing wrapper:\n\nasync function safeReserve(installId, installSecret, amount, jobId) {\n  try {\n    const res = await fetch(\"https://cloud.officex.app/v1/reserve\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": installId,\n        \"x-officex-install-secret\": installSecret,\n      },\n      body: JSON.stringify({ amount, job_id: jobId }),\n      signal: AbortSignal.timeout(5000),\n    }).then((r) => r.json());\n    if (!res.success) {\n      console.error(`OfficeX reserve failed: ${res.error?.code}`, res.error);\n      return res;\n    }\n    return res;\n  } catch (err) {\n    console.error(\"OfficeX API unreachable, queuing for retry:\", err.message);\n    await billingRetryQueue.enqueue({\n      installId,\n      amount,\n      jobId,\n      attemptedAt: new Date(),\n    });\n    return {\n      success: false,\n      error: { code: \"OFFICEX_UNREACHABLE\", message: err.message },\n    };\n  }\n}\n\nError handling for reservations:\n\nasync function reserveWithRetry(installId, amount, jobId) {\n  try {\n    return await reserve(installId, amount, jobId);\n  } catch (error) {\n    if (error.code === \"INSTALL_EXPIRED\") {\n      await sendInboxMessage(installId, {\n        id: `renew-${jobId}`,\n        title: \"Authorization Expired\",\n        text: \"Please renew your authorization to continue.\",\n      });\n    }\n    if (error.code === \"INSUFFICIENT_FUNDS\") {\n      await sendInboxMessage(installId, {\n        id: `topup-${jobId}`,\n        title: \"Low Balance\",\n        text: `Need ${amount} credits. Please top up.`,\n      });\n    }\n    if (error.code === \"RATE_LIMITED\") {\n      await sendInboxMessage(installId, {\n        id: `ratelimit-${jobId}`,\n        title: \"Rate Limit Reached\",\n        text: \"You've hit your usage limit for this period.\",\n      });\n    }\n    throw error;\n  }\n}\n\nPractical guidelines:\n\nSet aggressive timeouts (3-5 seconds) on all OfficeX API calls\nIf reserve fails, consider letting user proceed and retrying billing later\nWebhooks are fire-and-forget — always have a fallback path (create users on-the-fly from iframe params)\nConsider a simple toggle to disable OfficeX billing during development/testing\nLog OfficeX errors instead of throwing unhandled exceptions"
      },
      {
        "title": "Complete Integration Example",
        "body": "1. Register app:\n\ncurl -X POST https://cloud.officex.app/v1/register-app \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-user-id: $OFFICEX_USER_ID\" \\\n  -H \"x-officex-master-key: $OFFICEX_API_KEY\" \\\n  -d '{\n    \"name\": \"My Awesome App\",\n    \"price_type\": \"PAY_PER_USE\",\n    \"webhook_url\": \"https://myapp.com/webhooks/officex\",\n    \"iframe_url\": \"https://myapp.com/officex\",\n    \"documentation\": \"## API\\n\\nCall POST /api/enrich with {leadId} to enrich a lead.\",\n    \"context_prompt\": \"This app enriches leads. Use the /api/enrich endpoint.\"\n  }'\n\n2. Handle webhook:\n\napp.post(\"/webhooks/officex\", async (req, res) => {\n  const { event, payload } = req.body;\n  if (event === \"INSTALL\") {\n    const user = await db.users.upsert({\n      officex_user_id: payload.user_id,\n      officex_install_id: payload.install_id,\n      officex_install_secret: payload.install_secret,\n      email: payload.email,\n    });\n    return res.json({\n      agent_context: {\n        api_key: user.apiKey,\n        base_url: \"https://myapp.com/api/v1\",\n      },\n    });\n  }\n  res.json({ ok: true });\n});\n\n3. Handle iframe entry:\n\napp.get(\"/officex\", async (req, res) => {\n  const user = await resolveUser(\n    req.query.officex_install_id,\n    req.query.officex_customer_id,\n  );\n  req.session.user = user;\n  req.session.officex = {\n    install_id: req.query.officex_install_id,\n    install_secret: req.query.officex_install_secret,\n  };\n  res.redirect(\"/dashboard\");\n});\n\n4. Bill from your app:\n\napp.post(\"/api/enrich-lead\", async (req, res) => {\n  const { install_id, install_secret } = req.session.officex;\n  const { leadId } = req.body;\n\n  const reservation = await safeReserve(\n    install_id,\n    install_secret,\n    5,\n    `enrich-${leadId}-${Date.now()}`,\n  );\n  if (!reservation.success)\n    return res.status(400).json({ error: reservation.error });\n\n  const enrichedData = await enrichLead(leadId);\n\n  await fetch(\n    `https://cloud.officex.app/v1/reservations/${reservation.reservation_id}/settle`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": install_id,\n        \"x-officex-install-secret\": install_secret,\n      },\n      body: JSON.stringify({ amount: 5, final: true }),\n    },\n  );\n\n  res.json({ success: true, data: enrichedData });\n});"
      },
      {
        "title": "Quick Start Workflows",
        "body": "Consumer — Register + Fund + Install:\n\ncurl -X POST $BASE/auth/register -d '{\"email\":\"user@example.com\"}'\ncurl -X POST $BASE/auth/verify-otp -d '{\"email\":\"user@example.com\",\"code\":\"0000\"}'\n# → { api_key, user_id, wallet_id }\ncurl -X POST $BASE/purchase-credits -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"amount\":1000,\"payment_method\":{\"type\":\"stripe\",\"token\":\"tok_xxx\"}}'\ncurl -X POST $BASE/install/$APP_ID -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\"\n# → { install_id, install_secret }\n\nDeveloper — Register App + Billing:\n\ncurl -X POST $BASE/register-app -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"name\":\"My App\",\"price_type\":\"PAY_PER_USE\",\"webhook_url\":\"https://myapp.com/webhooks/officex\"}'\n# → { app_id, destination_wallet_id }\n\n# From app server (using install secret from webhook):\ncurl -X POST $BASE/reserve -H \"x-officex-install-id: $IID\" -H \"x-officex-install-secret: $SEC\" \\\n  -d '{\"amount\":10,\"job_id\":\"job-1\"}'\ncurl -X POST $BASE/settle -H \"x-officex-install-id: $IID\" -H \"x-officex-install-secret: $SEC\" \\\n  -d '{\"reservation_id\":\"RESV#job-1\",\"amount\":8,\"final\":true}'\n# 2 credits refunded to user, 8 credited to app wallet\n\nVendor Payout:\n\ncurl -X POST $BASE/payout -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"wallet_id\":\"$APP_WALLET\",\"amount\":500,\"destination\":{\"type\":\"stripe\",\"account_id\":\"acct_xxx\"}}'"
      },
      {
        "title": "Tracers (Cross-Thread Correlation)",
        "body": "A tracer_id is a string (format: trc_{timestamp36}-{random}) that links causally-related threads and schedule runs. Use cases: schedule runs weekly → each run creates a thread → all threads share one tracer_id → frontend can render a unified timeline.\n\nWhere tracer_id appears:\n\nThreads: POST /users/me/chats body accepts tracer_id. GET /users/me/chats?tracer_id=trc_xxx filters by tracer. PATCH can set/clear it (null to remove).\nSchedules: POST /users/me/schedules body accepts tracer_id (auto-generated as trc_* if omitted). All schedule responses include it.\nSchedule Runs: Each run record includes tracer_id copied from the schedule.\nChat Stream: Body accepts tracer_id. Stream protocol emits tracer:{tracerId}\\n after t:{threadId}\\n.\n\nExample flow:\n\nCreate schedule → tracer_id: \"trc_abc123\" auto-generated\nSchedule runs → creates thread with tracer_id: \"trc_abc123\"\nQuery GET /users/me/chats?tracer_id=trc_abc123 → returns all threads from this schedule"
      },
      {
        "title": "FAQ",
        "body": "How do I test without real money? Use vouchers via admin panel or test voucher codes.\n\nCan my app have multiple pricing tiers? Yes. Your app logic decides how many credits to reserve per action.\n\nWhat happens if my webhook is down? Webhooks are fire-and-forget. Handle gracefully by polling installation status or creating users on-the-fly from iframe params.\n\nCan I transfer credits between apps I own? No. Credits only flow through reservation/settlement. Each app wallet is independent.\n\nHow do I handle long-running jobs that fail? Reserve upfront, sip as work completes, cancel on failure (refunds all unsettled credits), send inbox message explaining what happened.\n\nHow does the AI chat agent interact with my app? The agent receives your documentation and context_prompt. If agent_context has credentials, the agent calls your API via http_request tool. Make sure docs include clear API instructions."
      }
    ],
    "body": "OfficeX Platform\n\nOfficeX is a membership-based app store. Users buy credits ($0.03 each: $0.02 profit + $0.01 ecosystem liability). Apps charge credits via reserve/settle. Vendors earn credits and payout to fiat (USDC on Solana or bank transfer, $0.01/credit).\n\nGet your credentials at: https://officex.app/store/en/developer/\n\nEnvironments\nEnv\tAPI Base\tChat Stream\nStaging (default)\thttps://staging-backend.cloud.officex.app/v1\thttps://chat-staging.cloud.officex.app/\nProduction\thttps://cloud.officex.app/v1\thttps://chat.cloud.officex.app/\nAuthentication\nMode\tHeaders\tScope\nNone\t—\tPublic catalog, vouchers, auth endpoints\nMaster Key\tx-officex-user-id + x-officex-master-key\tProfile, installs, wallets, vendor apps\nInstall Secret\tx-officex-install-id + x-officex-install-secret\tBilling: reserve, settle, cancel, inbox\nInstall Secret (alt)\tx-officex-user-id + x-officex-app-id + x-officex-install-secret\tSame as above (alternative lookup)\nSuperadmin\tx-officex-admin-secret\tFull system (/admin/*)\n\nInstall Secret is billing only — your app manages its own user authentication separately. The install secret handles the money, your app handles everything else.\n\nCredit Economy\nUser buys credits → Treasury liability increases (zero-sum: Treasury + all wallets = 0)\nApp reserves credits → Locked from user wallet\nApp sips/settles → Credits move to app wallet\nVendor payouts → Credits converted to fiat, Treasury liability decreases\n\nDecimal Credits\n\nCredits support decimals. Rounding up to 1 credit ($0.03) is expensive for small operations:\n\nOperation\tCredits\tUser Pays\nMicro task\t0.1\t$0.003\nSmall task\t0.25\t$0.0075\nMedium task\t0.5\t$0.015\nStandard\t1.0\t$0.03\n\nBest practice: Price based on actual cost. If an API call costs $0.001, charge ~0.07 credits (2x markup).\n\nDual Roles\n\nA single OfficeX account can be both Consumer (install and use apps) and Vendor (create apps and earn credits).\n\nAPI Endpoints\nAuth (No Auth)\nPOST /auth/register              { email }                     → { success, message }\nPOST /auth/login                 { email, password? }          → { success, api_key?, user_id? }\nPOST /auth/verify-otp            { email, code }               → { success, api_key, user_id, wallet_id? }\nPOST /auth/resend                { email }                     → { success, message }\nPOST /auth/forgot-password       { email }                     → { success, message }\nPOST /auth/reset-password        { email, code, new_password } → { success, message }\nPOST /auth/set-password          { password }            [MK]  → { success, message }\nPOST /auth/rotate-key            { email }                     → { success, message }\nPOST /auth/confirm-rotate-key    { email, code }               → { success, api_key }\nPOST /register-user              { email }                     → { user_id, wallet_id, api_key }  (legacy)\n\n\nTesting mode: OTP hardcoded to 0000, email sending disabled.\n\nUser Profile [Master Key]\nGET    /users/me                                → { user: { user_id, email, wallet_id, status } }\nPATCH  /users/me                 { email? }     → { user }\nPOST   /users/me/rotate-key     { new_master_key } → { success }\nGET    /users/me/vouchers                       → { vouchers[] }\n\nInstallations [Master Key]\nGET    /users/me/installs                       → { installs[] }\nGET    /users/me/installs/{id}                  → { install (with usage stats) }\nPOST   /install/{app_id_or_slug} { max_per_hour?, max_per_day?, max_per_month?, allowed_until? }\n                                                → { app_id, install_id, install_secret, agent_context? }  ⚠️ secret shown once\nDELETE /users/me/installs/{id}                  → { success }\nPATCH  /users/me/installs/{id}  { max_per_hour?, max_per_day?, max_per_month?, allowed_until?, lifetime_spend_limit? }\n                                                → { install }\nPOST   /users/me/installs/{id}/rotate-secret    → { install_secret }\nPATCH  /users/me/installs/{id}/context  { key: val | null } → { agent_context }\n\n\nallowed_until: unix timestamp (-1 = never, default = now + 30d). lifetime_spend_limit: -1 = unlimited.\n\nApp-scoped routes (Install Secret auth):\n\nPATCH  /installs/{install_id}/context  { key: val | null } → { agent_context }\nPOST   /installs/{install_id}/inbox    { id, title, text, url?, icon? } → { message_id }\n\nVendor Apps [Master Key]\nGET    /users/me/apps                           → { apps[] }\nPOST   /register-app             (see full schema below)\n                                                → { app_id, destination_wallet_id }\nGET    /users/me/apps/{app_id}                  → { app }\nPATCH  /users/me/apps/{app_id}   (see full schema below)\n                                                → { app }\nDELETE /users/me/apps/{app_id}                  → { success }\nGET    /users/me/apps/{app_id}/inbox            → { reservations[], pagination? }\nPOST   /users/me/apps/{app_id}/inbox/{log_id}/ack → { success }\nGET    /users/me/apps/{app_id}/installs         → { installs[] }\n\nPublic Catalog (No Auth)\nGET    /apps                                    → { apps[], pagination? }\nGET    /apps/{app_id}                           → { app }\n\nCredits & Balance [Master Key]\nPOST   /purchase-credits         { amount, payment_method: { type, token }, idempotency_key? }\n                                                → { credits_added, new_balance, transaction_id }\nGET    /balance                                 → { wallet_id, available, reserved, total }\n\nReserve & Settle [Install Secret]\nPOST   /reserve                  { amount, job_id, metadata? }\n                                                → { reservation_id, amount_reserved }\nPOST   /settle                   { reservation_id, amount, final? }\n                                                → { settled_amount, remaining_reserved, status }\nGET    /reservations/{id}                       → { reservation }\nPOST   /reservations/{id}/cancel                → { refunded_amount, status }\nPOST   /reservations/{id}/settle { amount, final? } → { settled_amount, status }\n\n\nfinal: true = complete + refund remainder. final: false (default) = sip (partial).\n\nReserve errors: INSTALL_EXPIRED, RATE_LIMITED, INSUFFICIENT_FUNDS, DUPLICATE_JOB, LIFETIME_LIMIT_REACHED\n\nWallets [Master Key]\nGET    /wallets/{id}                            → { wallet_id, available, reserved, total, owner_type }\nGET    /wallets/{id}/transactions  ?limit=&cursor= → { transactions[], pagination? }\nGET    /wallets/{id}/transactions/{log_id}      → { transaction }\nGET    /wallets/{id}/reservations  ?direction=   → { reservations[] }\nGET    /wallets/{id}/reservations/{resv_id}     → { reservation }\nGET    /wallets/{id}/payouts                    → { payouts[] }\nGET    /wallets/{id}/payouts/{payout_id}        → { payout }\n\nPayouts [Master Key]\nPOST   /payout                   { wallet_id, amount, destination: { type, account_id }, idempotency_key? }\n                                                → { payout_id, status }\n\n\nPayout state machine: pending → burned → completed | failed (failed = credits restored). Frequency: end of every month. Rate: $0.01 per credit.\n\nVouchers\nGET    /vouchers/{code}                         → { voucher }           (No Auth)\nPOST   /vouchers/{code}/redeem   { wallet_id }  → { credits_added }    [Master Key]\n\nChat [Master Key]\nGET    /users/me/chats  ?project_id=&tracer_id= → { threads[] }\nPOST   /users/me/chats           { title?, project_id?, tracer_id? }     → { thread }\nGET    /users/me/chats/{id}                     → { thread, messages[] }\nPATCH  /users/me/chats/{id}      { title?, tracer_id?: string|null }      → { thread }\nDELETE /users/me/chats/{id}                     → { success }\n\n\nStreaming (Function URL, NOT API Gateway):\n\nPOST   <CHAT_STREAM_URL>\nHeaders: x-officex-user-id, x-officex-master-key\nBody: { messages[], thread_id?, project_id?, system_prompt?, tracer_id?, include_apps? }\nResponse: text/event-stream (SSE)\n\n\nStream protocol lines (emitted before SSE data):\n\nt:{threadId}\\n — resolved thread ID (always emitted)\ntracer:{tracerId}\\n — tracer ID (emitted when tracer_id present in request)\ns:{json}\\n — status updates\nInbox, Prompts, Refs, Uploads [Master Key]\nGET    /users/me/inbox                          → { messages[] }\nGET/POST/PATCH/DELETE /users/me/prompts[/{id}]  → prompt CRUD\nGET    /refs/{slug}                             → { ref }  (No Auth)\nGET/POST /users/me/refs[/{slug}]                → ref CRUD [Master Key]\nPOST   /uploads/presign { filename, content_type } → { presigned_url, key }\n\nAdmin [Superadmin]\n\nAll under /admin/* with x-officex-admin-secret:\n\nUsers: GET /admin/users, GET/PATCH /admin/users/{id}, GET /admin/users/{id}/wallets Apps: GET /admin/apps, GET/PATCH/DELETE /admin/apps/{id} Wallets: GET /admin/wallets, GET /admin/wallets/{id}, POST /admin/wallets/{id}/adjust { amount } Treasury: GET /admin/treasury, POST /admin/reconcile, GET /admin/audit Payouts: GET /admin/payouts, GET /admin/payouts/{id}, POST /admin/payouts/{id}/approve, POST /admin/payouts/{id}/reject Vouchers: POST /admin/vouchers, GET /admin/vouchers, GET/PATCH/DELETE /admin/vouchers/{code}\n\nError Format\n{ \"success\": false, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n\nCode\tHTTP\tDescription\nUNAUTHORIZED\t401\tMissing or invalid auth headers\nINVALID_SECRET\t401\tSecret doesn't match hash\nINVALID_REQUEST\t400\tMalformed request body\nAPP_NOT_FOUND\t404\tApp doesn't exist\nUSER_NOT_FOUND\t404\tUser doesn't exist\nWALLET_NOT_FOUND\t404\tWallet doesn't exist\nRESERVATION_NOT_FOUND\t404\tReservation doesn't exist\nINSTALL_NOT_FOUND\t404\tInstallation doesn't exist\nINSTALL_EXPIRED\t403\tBilling authorization expired\nRATE_LIMITED\t429\tExceeded rate limit\nINSUFFICIENT_FUNDS\t402\tWallet balance too low\nLIFETIME_LIMIT_REACHED\t403\tCumulative spending exceeds lifetime limit\nFORBIDDEN\t403\tAccessing another user's resources\nDUPLICATE_REQUEST\t409\tIdempotency key collision\nDUPLICATE_JOB\t409\tJob ID already has reservation\nPREVIOUS_FAILURE\t409\tIdempotency collision (failed, use new key)\nPAYMENT_FAILED\t402\tExternal payment rejected\nPAYOUT_FAILED\t500\tFiat transfer failed\nINTERNAL_ERROR\t500\tUnexpected server error\nUsing 3rd Party Apps\n\nWhenever you use an app on OfficeX, you can grab the agent_context from the app installation. This may give you app secrets to interact with their REST API. However, not all apps might have it. Every app has their own skill.md that you can copy online or request via OfficeX API.\n\nApp Lifecycle (Developer Guide)\n1. Create Your App\n\nEndpoint: POST /register-app [Master Key]\n\n{\n  name: string,                    // Required: 3-50 chars\n  description?: string,\n  price_type?: \"FREE\" | \"PAY_PER_USE\" | \"ONE_TIME\" | \"SUBSCRIPTION\" | \"MIXED\",\n  webhook_url?: string,            // HTTPS URL for lifecycle events\n  suggested_rate_limits?: { max_per_hour?, max_per_day?, max_per_month?, expires_at? },\n  minimum_rate_limits?: { max_per_hour?, max_per_day?, max_per_month?, expires_at? },\n  subtitle?: string,               // Max 200 chars\n  category?: string,               // Max 50 chars\n  developer?: string,\n  app_url?: string,\n  iframe_url?: string,             // URL for embedded iframe experience\n  support_url?: string,\n  contact_email?: string,\n  context_prompt?: string,         // AI agent instructions (max 5000 chars)\n  documentation?: string,          // API docs for AI agent (max 50000 chars)\n  pricing_lines?: string[],        // Max 10 items, 100 chars each\n  tags?: string[],                 // Max 10\n  icon?: { type: \"emoji\" | \"image\", content: string },\n  inAppPurchases?: boolean\n}\n// Response (201): { success, app_id, destination_wallet_id, message }\n\n\nExample:\n\ncurl -X POST https://cloud.officex.app/v1/register-app \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-user-id: $OFFICEX_USER_ID\" \\\n  -H \"x-officex-master-key: $OFFICEX_API_KEY\" \\\n  -d '{\n    \"name\": \"Lead Enrichment Pro\",\n    \"description\": \"Enrich B2B leads with company data\",\n    \"price_type\": \"PAY_PER_USE\",\n    \"subtitle\": \"B2B lead enrichment powered by AI\",\n    \"category\": \"Marketing\",\n    \"developer\": \"Acme Corp\",\n    \"webhook_url\": \"https://myapp.com/webhooks/officex\",\n    \"iframe_url\": \"https://myapp.com/officex\",\n    \"documentation\": \"## API Reference\\n\\nThis app enriches leads...\",\n    \"context_prompt\": \"This app enriches B2B leads. When the user asks to enrich leads, call the /enrich endpoint.\",\n    \"suggested_rate_limits\": { \"max_per_hour\": 50, \"max_per_day\": 200, \"max_per_month\": 2000 },\n    \"pricing_lines\": [\"5 credits per lead enrichment\", \"Bulk discount: 3 credits for 10+ leads\"]\n  }'\n\n\nEach app gets a discrete wallet (destination_wallet_id). Earnings go there, not your personal wallet. documentation is injected into the AI chat agent's system prompt. context_prompt provides additional instructions for the AI agent.\n\n2. Update Your App\n\nEndpoint: PATCH /users/me/apps/{app_id} [Master Key] — All fields optional, set to null to clear:\n\n{\n  name?, description?, price_type?, webhook_url?, iframe_url?, app_url?,\n  subtitle?, category?, developer?, support_url?, contact_email?,\n  context_prompt?, documentation?, pricing_lines?, tags?,\n  icon?, previews?, icon_url?, preview_images?, youtube_url?,\n  suggested_rate_limits?, minimum_rate_limits?, inAppPurchases?\n}\n\n3. List App's Installs (Vendor View)\n\nEndpoint: GET /users/me/apps/{app_id}/installs [Master Key]\n\n// Response (200)\n{\n  success: true,\n  installs: Array<{\n    user_id: string, install_id: string, nickname?: string, status: string,\n    installed_at: string, max_per_hour: number, max_per_day: number,\n    max_per_month: number, allowed_until: number,\n    usage: { hour: number, day: number, month: number }\n  }>\n}\n\nInstallation Flow (When Users Install Your App)\n\nWhen a user installs your app, OfficeX:\n\nCreates an Installation Record linking user to app\nGenerates an Install ID and Install Secret (scoped billing credentials)\nSets rate limits (user-specified or your suggested defaults)\nSets allowed_until expiry (default: 30 days, or -1 for no expiry)\nFires an INSTALL webhook to your webhook_url (if configured)\n\nThe install endpoint accepts both app_id (UUID) and slug (string) in the path parameter.\n\nWebhook Events\n\nYour app receives lifecycle events at webhook_url. Envelope: { event, payload, uuid }.\n\nINSTALL Event:\n\n{\n  \"event\": \"INSTALL\",\n  \"payload\": {\n    \"install_id\": \"uuid-of-installation\",\n    \"install_secret\": \"base64url-encoded-secret\",\n    \"user_id\": \"uuid-of-user\",\n    \"app_id\": \"uuid-of-your-app\",\n    \"email\": \"user@example.com\",\n    \"timestamp\": \"2025-01-25T10:30:00Z\"\n  },\n  \"uuid\": \"unique-request-id\"\n}\n\n\nUNINSTALL Event: payload: { install_id, user_id, app_id, timestamp }\n\nRATE_LIMIT_CHANGE Event: payload: { install_id, user_id, app_id, max_per_hour, max_per_day, max_per_month, allowed_until, timestamp }\n\nWebhook Response: Your response for INSTALL can include agent_context (only this key is extracted). Values are stored on the installation and injected into the user's AI agent prompt:\n\n{\n  \"agent_context\": {\n    \"api_key\": \"sk-abc123\",\n    \"workspace_id\": \"ws-456\",\n    \"base_url\": \"https://myapp.com/api/v1\"\n  }\n}\n\n\nDelivery: POST, application/json, 25s timeout, fire-and-forget (no retries in v1).\n\nNote: Not all apps need a webhook. Apps where the user supplies their own credentials (e.g., Telegram bot token) can skip the webhook entirely and use PATCH /installs/{install_id}/context from within the app UI post-install. See Agent Context for details.\n\nCredit Billing System\nThe Reserve → Sip → Settle Pattern\n┌─────────────┐     ┌─────────────┐     ┌─────────────┐\n│   RESERVE   │────►│     SIP     │────►│   SETTLE    │\n│  Lock funds │     │  Progressive│     │  Finalize   │\n│  for job    │     │  billing    │     │  + refund   │\n└─────────────┘     └─────────────┘     └─────────────┘\n                          │\n                          ▼ (if job fails)\n                    ┌─────────────┐\n                    │   CANCEL    │\n                    │  Full refund│\n                    └─────────────┘\n\nReserve (Lock Funds)\n\nPOST /reserve [Install Secret]\n\n// Request\n{ amount: number, job_id: string, metadata?: Record<string, unknown> }\n// Response (200)\n{ success: true, reservation_id: string, amount_reserved: number }\n\ncurl -X POST https://cloud.officex.app/v1/reserve \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-install-id: $INSTALL_ID\" \\\n  -H \"x-officex-install-secret: $INSTALL_SECRET\" \\\n  -d '{ \"amount\": 10, \"job_id\": \"lead-enrich-job-12345\", \"metadata\": { \"leads_count\": 50 } }'\n\n\nInternally: user's wallet.available decreases, wallet.reserved increases, creates RESV#<job_id>.\n\nError Code\tMeaning\nINSTALL_EXPIRED\tallowed_until timestamp has passed\nLIFETIME_LIMIT_REACHED\tCumulative spending exceeds limit\nRATE_LIMITED\tHourly/daily/monthly limit exceeded\nINSUFFICIENT_FUNDS\tNot enough available credits\nDUPLICATE_JOB\tThis job_id already has a reservation\nSip (Progressive Settlement)\n\nPOST /reservations/{reservation_id}/settle (or POST /settle) [Install Secret]\n\n// Request (partial)\n{ amount: number, final: false }\n// Response (200)\n{ success: true, settled_amount: number, remaining_reserved: number, status: \"partial\" }\n\n\nExample — enriching 100 leads:\n\nReserve 100 credits\n├── Sip 10 (processed 10 leads) → settled: 10, reserved: 90\n├── Sip 10 (processed 20 leads) → settled: 20, reserved: 80\n├── Sip 10 (processed 30 leads) → settled: 30, reserved: 70\n└── Settle final 70 → settled: 100, reserved: 0, status: \"completed\"\n\nSettle (Final)\n// Request (final)\n{ amount: number, final: true }\n// Response (200)\n{ success: true, settled_amount: number, remaining_reserved: number, status: \"completed\" }\n\n\nRemaining reserved funds (if any) are refunded to user. Credits move to your app's wallet.\n\nCancel (Full Refund)\n\nPOST /reservations/{reservation_id}/cancel [Install Secret]\n\n// Response (200)\n{ success: true, refunded_amount: number, status: \"cancelled\" }\n\nRate Limits & Allowances\n\nRate limits are per (user, app) pair — each installation has independent limits.\n\nWindow\tDefault\tDescription\nHourly\t100 reservations\tResets each hour\nDaily\t300 reservations\tResets each day\nMonthly\t1000 reservations\tResets each month\n\nMinimum rate limits (developer-set floor): Users cannot go below these values.\n\nallowed_until: Unix timestamp (-1 = never expires, default = now + 30 days). When expired, all /reserve calls fail with INSTALL_EXPIRED. Enables pseudo-subscription billing.\n\nlifetime_spend_limit: Total credits an app can ever charge across all time. Set to -1 for unlimited. Fails with LIFETIME_LIMIT_REACHED when exceeded.\n\nApp Type\tSuggested Hourly\tDaily\tMonthly\nLead enrichment\t50\t200\t2000\nData export\t10\t50\t200\nAI generation\t20\t100\t1000\nReal-time lookup\t100\t500\t5000\nAgent Context (AI Chat Integration)\n\nWhen users chat with OfficeX AI, the agent receives your app's documentation and context_prompt. Store per-install credentials via:\n\nPATCH /users/me/installs/{install_id}/context [Master Key] or PATCH /installs/{install_id}/context [Install Secret]\n\n// Request: Record<string, string | null> (null deletes key)\n{ \"api_key\": \"sk-abc123\", \"workspace_id\": \"ws-456\" }\n// Response (200)\n{ success: true, agent_context: { \"api_key\": \"sk-abc123\", \"workspace_id\": \"ws-456\" } }\n\n\nValidation: Max 50 keys, 200 chars/key, 1000 chars/value.\n\nTwo patterns for setting agent_context:\n\nWebhook-response (auto): Return credentials in your INSTALL webhook response → auto-applied as agent_context. Best for apps that provision credentials server-side on install.\n\nPost-install from app UI (manual): App collects credentials from the user inside its own iframe/UI after install, then PATCHes them via install secret auth. Best for apps where the user supplies their own API key/token (e.g., Telegram bot token, OpenAI key, Stripe key). Flow:\n\nUser installs app (no extra params needed)\nUser opens app → enters their credentials in the app's UI\nApp calls PATCH /installs/{install_id}/context with install secret auth\nCredentials are stored on the installation → available to AI agent via load_app_skill\n# App-side context update (install secret auth)\nPATCH /v1/installs/{install_id}/context\nHeaders: X-Officex-Install-Id: {install_id}, X-Officex-Install-Secret: {install_secret}\nBody: { \"telegram_bot_token\": \"123456:ABC-DEF...\" }\n→ { success: true, agent_context: { \"telegram_bot_token\": \"123456:ABC-DEF...\" } }\n\nSending Inbox Messages\n\nNotify users about job status, results, or important updates:\n\nPOST /installs/{install_id}/inbox [Install Secret]\n\n// Request\n{\n  id: string,          // Idempotency key (unique per app+user)\n  title: string,       // Max 200 chars\n  text: string,        // Max 2000 chars\n  url?: string,        // Link to results\n  icon?: string        // Icon URL\n}\n// Response (201) — New message\n{ success: true, message_id: string }\n// Response (200) — Deduplicated\n{ success: true, message_id: string, deduplicated: true }\n\ncurl -X POST https://cloud.officex.app/v1/installs/$INSTALL_ID/inbox \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-install-id: $INSTALL_ID\" \\\n  -H \"x-officex-install-secret: $INSTALL_SECRET\" \\\n  -d '{\n    \"id\": \"job-12345-complete\",\n    \"title\": \"Lead Enrichment Complete\",\n    \"text\": \"Successfully enriched 50 leads. 3 could not be found.\",\n    \"url\": \"https://myapp.com/results/12345\"\n  }'\n\nBilling Patterns\nPattern 1: Free Apps\n\nNo reservations needed. Use inbox messages to communicate.\n\nPattern 2: One-Time Purchase\n\nReserve and settle immediately for discrete actions:\n\nconst reservation = await reserve({ amount: 0.5, job_id: `enrich-${leadId}` });\nconst result = await enrichLead(leadId);\nawait settle({ amount: 0.5, final: true });\n\nPattern 3: Usage-Based (Progressive Sip)\n\nFor long-running jobs, bill incrementally:\n\nconst reservation = await reserve({ amount: 10, job_id: `batch-${batchId}` });\nfor (const item of items) {\n  await processItem(item);\n  await settle({ amount: 0.1, final: false }); // sip per item\n}\nawait settle({ amount: 0, final: true }); // finalize, refund unused\n\nPattern 4: Subscription-like\n\nUse allowed_until for time-based access:\n\nif (\n  install.allowed_until !== -1 &&\n  Date.now() / 1000 >= install.allowed_until\n) {\n  return { error: \"Please renew your subscription\" };\n}\n\nPattern 5: Internal Credits System (Recommended)\n\nDecouple your app from OfficeX API by maintaining your own internal ledger:\n\nReserve + settle OfficeX credits in bulk\nMint equivalent internal credits in your DB\nYour app logic consumes internal credits only — no OfficeX API calls during normal operation\nOfficeX Credits (external)          Your App Credits (internal)\n┌─────────────────────┐             ┌─────────────────────┐\n│ User's OfficeX      │  reserve    │                     │\n│ wallet              │────────────►│  (funds locked)     │\n│                     │  settle     │  Internal ledger    │\n│                     │────────────►│  += settled amount  │\n│                     │             │  App consumes from  │\n│                     │             │  internal ledger    │\n└─────────────────────┘             └─────────────────────┘\n\nasync function settleAndMintCredits(\n  reservationId,\n  installId,\n  installSecret,\n  amount,\n  final,\n  userId,\n) {\n  const result = await fetch(\n    `https://cloud.officex.app/v1/reservations/${reservationId}/settle`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": installId,\n        \"x-officex-install-secret\": installSecret,\n      },\n      body: JSON.stringify({ amount, final }),\n    },\n  ).then((r) => r.json());\n  if (!result.success) throw new Error(result.error.message);\n  await db.internalCredits.increment(userId, amount);\n  return result;\n}\n\n// Reserve a block, settle immediately, user spends internal credits freely\nconst reservation = await reserve({\n  amount: 50,\n  job_id: `session-${sessionId}`,\n});\nawait settleAndMintCredits(\n  reservation.reservation_id,\n  installId,\n  installSecret,\n  50,\n  true,\n  userId,\n);\n// Now user has 50 internal credits — no more OfficeX API calls needed\n\n\nBenefits: decoupled from API availability, flexible internal pricing, auditability, batch funding, simpler error handling.\n\nFrontend Integration (Iframe Embedding)\n\nWhen users launch your app from OfficeX, it loads in an iframe with credentials as URL params:\n\nhttps://your-app.com/officex?officex_customer_id={user_id}&officex_install_id={install_id}&officex_install_secret={install_secret}\n\nExtracting Credentials\n\nJavaScript/TypeScript:\n\nconst params = new URLSearchParams(window.location.search);\nconst customerId = params.get(\"officex_customer_id\");\nconst installId = params.get(\"officex_install_id\");\nconst installSecret = params.get(\"officex_install_secret\");\n\nif (!installId || !installSecret) {\n  showError(\"Please access this app through the OfficeX app store\");\n  return;\n}\nsessionStorage.setItem(\"officex_install_id\", installId);\nsessionStorage.setItem(\"officex_install_secret\", installSecret);\n\n\nPython (Flask):\n\n@app.route('/officex')\ndef officex_entry():\n    install_id = request.args.get('officex_install_id')\n    install_secret = request.args.get('officex_install_secret')\n    if not install_id or not install_secret:\n        return \"Please access this app through OfficeX\", 403\n    session['officex_install_id'] = install_id\n    session['officex_install_secret'] = install_secret\n    return render_template('app.html')\n\nRequired: Allow OfficeX to Embed Your App\n\nYour app must allow the OfficeX domain to embed it via iframe:\n\nContent-Security-Policy: frame-ancestors 'self' https://officex.app https://*.officex.app\n\n\nNext.js (next.config.js):\n\nmodule.exports = {\n  async headers() {\n    return [\n      {\n        source: \"/:path*\",\n        headers: [\n          {\n            key: \"Content-Security-Policy\",\n            value:\n              \"frame-ancestors 'self' https://officex.app https://*.officex.app\",\n          },\n        ],\n      },\n    ];\n  },\n};\n\n\nExpress.js:\n\napp.use((req, res, next) => {\n  res.setHeader(\n    \"Content-Security-Policy\",\n    \"frame-ancestors 'self' https://officex.app https://*.officex.app\",\n  );\n  next();\n});\n\n\nWithout this header: blank screen or console errors about \"refused to frame.\"\n\nSecurity:\n\nNever expose install_secret to users (use sessionStorage, not localStorage)\nValidate on backend for sensitive operations\nHTTPS required\nHandle missing params gracefully (users may bookmark deep links)\nWebhook Authentication Flow (SSO)\n\nThe INSTALL webhook enables seamless single sign-on:\n\nUser clicks \"Install\" → OfficeX POSTs webhook → Your app creates/links user\n→ Returns agent_context → User launches iframe → Lookup by install_id → Authenticated!\n\nImplementing onInstall Authentication\n\nStep 1: Handle webhook:\n\napp.post(\"/webhooks/officex\", async (req, res) => {\n  const { event, payload, uuid } = req.body;\n  if (event === \"INSTALL\") {\n    const { install_id, install_secret, user_id, app_id, email } = payload;\n    let user = await db.users.findOne({ officex_user_id: user_id });\n    if (!user) {\n      user = await db.users.create({\n        officex_user_id: user_id,\n        officex_install_id: install_id,\n        created_at: new Date(),\n      });\n    } else {\n      await db.users.update(\n        { officex_user_id: user_id },\n        { officex_install_id: install_id },\n      );\n    }\n    // Return agent_context — credentials the AI agent needs to call your API\n    res.json({\n      agent_context: {\n        user_token: user.apiToken,\n        base_url: \"https://myapp.com/api/v1\",\n      },\n    });\n  } else {\n    res.json({ received: true });\n  }\n});\n\n\nStep 2: Look up user when iframe loads:\n\napp.get(\"/officex\", async (req, res) => {\n  const installId = req.query.officex_install_id;\n  const customerId = req.query.officex_customer_id;\n  const user = await resolveUser(installId, customerId);\n  req.session.user = user;\n  req.session.officex = {\n    install_id: installId,\n    install_secret: req.query.officex_install_secret,\n  };\n  res.redirect(\"/dashboard\");\n});\n\nHandling Webhook Failures\n\nWebhooks are fire-and-forget (no retries). Always have a fallback:\n\nasync function resolveUser(installId, customerId) {\n  // Try install_id first (most specific)\n  let user = await db.users.findOne({ officex_install_id: installId });\n  if (user) return user;\n  // Fall back to customer_id (they might have reinstalled)\n  user = await db.users.findOne({ officex_user_id: customerId });\n  if (user) {\n    await db.users.update({ id: user.id }, { officex_install_id: installId });\n    return user;\n  }\n  // No user found — create on-the-fly\n  return await db.users.create({\n    officex_user_id: customerId,\n    officex_install_id: installId,\n    created_at: new Date(),\n  });\n}\n\nFault Tolerance\n\nYour app should treat OfficeX as an external payment layer, not a core dependency.\n\nPrinciples:\n\nWrap all OfficeX API calls in try/catch with timeouts (3-5 seconds)\nYour app should function if all OfficeX code was removed — OfficeX is how you get paid, not your runtime\nUse the Internal Credits pattern (Pattern 5) for maximum resilience\n\nResilient billing wrapper:\n\nasync function safeReserve(installId, installSecret, amount, jobId) {\n  try {\n    const res = await fetch(\"https://cloud.officex.app/v1/reserve\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": installId,\n        \"x-officex-install-secret\": installSecret,\n      },\n      body: JSON.stringify({ amount, job_id: jobId }),\n      signal: AbortSignal.timeout(5000),\n    }).then((r) => r.json());\n    if (!res.success) {\n      console.error(`OfficeX reserve failed: ${res.error?.code}`, res.error);\n      return res;\n    }\n    return res;\n  } catch (err) {\n    console.error(\"OfficeX API unreachable, queuing for retry:\", err.message);\n    await billingRetryQueue.enqueue({\n      installId,\n      amount,\n      jobId,\n      attemptedAt: new Date(),\n    });\n    return {\n      success: false,\n      error: { code: \"OFFICEX_UNREACHABLE\", message: err.message },\n    };\n  }\n}\n\n\nError handling for reservations:\n\nasync function reserveWithRetry(installId, amount, jobId) {\n  try {\n    return await reserve(installId, amount, jobId);\n  } catch (error) {\n    if (error.code === \"INSTALL_EXPIRED\") {\n      await sendInboxMessage(installId, {\n        id: `renew-${jobId}`,\n        title: \"Authorization Expired\",\n        text: \"Please renew your authorization to continue.\",\n      });\n    }\n    if (error.code === \"INSUFFICIENT_FUNDS\") {\n      await sendInboxMessage(installId, {\n        id: `topup-${jobId}`,\n        title: \"Low Balance\",\n        text: `Need ${amount} credits. Please top up.`,\n      });\n    }\n    if (error.code === \"RATE_LIMITED\") {\n      await sendInboxMessage(installId, {\n        id: `ratelimit-${jobId}`,\n        title: \"Rate Limit Reached\",\n        text: \"You've hit your usage limit for this period.\",\n      });\n    }\n    throw error;\n  }\n}\n\n\nPractical guidelines:\n\nSet aggressive timeouts (3-5 seconds) on all OfficeX API calls\nIf reserve fails, consider letting user proceed and retrying billing later\nWebhooks are fire-and-forget — always have a fallback path (create users on-the-fly from iframe params)\nConsider a simple toggle to disable OfficeX billing during development/testing\nLog OfficeX errors instead of throwing unhandled exceptions\nComplete Integration Example\n\n1. Register app:\n\ncurl -X POST https://cloud.officex.app/v1/register-app \\\n  -H \"Content-Type: application/json\" \\\n  -H \"x-officex-user-id: $OFFICEX_USER_ID\" \\\n  -H \"x-officex-master-key: $OFFICEX_API_KEY\" \\\n  -d '{\n    \"name\": \"My Awesome App\",\n    \"price_type\": \"PAY_PER_USE\",\n    \"webhook_url\": \"https://myapp.com/webhooks/officex\",\n    \"iframe_url\": \"https://myapp.com/officex\",\n    \"documentation\": \"## API\\n\\nCall POST /api/enrich with {leadId} to enrich a lead.\",\n    \"context_prompt\": \"This app enriches leads. Use the /api/enrich endpoint.\"\n  }'\n\n\n2. Handle webhook:\n\napp.post(\"/webhooks/officex\", async (req, res) => {\n  const { event, payload } = req.body;\n  if (event === \"INSTALL\") {\n    const user = await db.users.upsert({\n      officex_user_id: payload.user_id,\n      officex_install_id: payload.install_id,\n      officex_install_secret: payload.install_secret,\n      email: payload.email,\n    });\n    return res.json({\n      agent_context: {\n        api_key: user.apiKey,\n        base_url: \"https://myapp.com/api/v1\",\n      },\n    });\n  }\n  res.json({ ok: true });\n});\n\n\n3. Handle iframe entry:\n\napp.get(\"/officex\", async (req, res) => {\n  const user = await resolveUser(\n    req.query.officex_install_id,\n    req.query.officex_customer_id,\n  );\n  req.session.user = user;\n  req.session.officex = {\n    install_id: req.query.officex_install_id,\n    install_secret: req.query.officex_install_secret,\n  };\n  res.redirect(\"/dashboard\");\n});\n\n\n4. Bill from your app:\n\napp.post(\"/api/enrich-lead\", async (req, res) => {\n  const { install_id, install_secret } = req.session.officex;\n  const { leadId } = req.body;\n\n  const reservation = await safeReserve(\n    install_id,\n    install_secret,\n    5,\n    `enrich-${leadId}-${Date.now()}`,\n  );\n  if (!reservation.success)\n    return res.status(400).json({ error: reservation.error });\n\n  const enrichedData = await enrichLead(leadId);\n\n  await fetch(\n    `https://cloud.officex.app/v1/reservations/${reservation.reservation_id}/settle`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"x-officex-install-id\": install_id,\n        \"x-officex-install-secret\": install_secret,\n      },\n      body: JSON.stringify({ amount: 5, final: true }),\n    },\n  );\n\n  res.json({ success: true, data: enrichedData });\n});\n\nQuick Start Workflows\n\nConsumer — Register + Fund + Install:\n\ncurl -X POST $BASE/auth/register -d '{\"email\":\"user@example.com\"}'\ncurl -X POST $BASE/auth/verify-otp -d '{\"email\":\"user@example.com\",\"code\":\"0000\"}'\n# → { api_key, user_id, wallet_id }\ncurl -X POST $BASE/purchase-credits -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"amount\":1000,\"payment_method\":{\"type\":\"stripe\",\"token\":\"tok_xxx\"}}'\ncurl -X POST $BASE/install/$APP_ID -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\"\n# → { install_id, install_secret }\n\n\nDeveloper — Register App + Billing:\n\ncurl -X POST $BASE/register-app -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"name\":\"My App\",\"price_type\":\"PAY_PER_USE\",\"webhook_url\":\"https://myapp.com/webhooks/officex\"}'\n# → { app_id, destination_wallet_id }\n\n# From app server (using install secret from webhook):\ncurl -X POST $BASE/reserve -H \"x-officex-install-id: $IID\" -H \"x-officex-install-secret: $SEC\" \\\n  -d '{\"amount\":10,\"job_id\":\"job-1\"}'\ncurl -X POST $BASE/settle -H \"x-officex-install-id: $IID\" -H \"x-officex-install-secret: $SEC\" \\\n  -d '{\"reservation_id\":\"RESV#job-1\",\"amount\":8,\"final\":true}'\n# 2 credits refunded to user, 8 credited to app wallet\n\n\nVendor Payout:\n\ncurl -X POST $BASE/payout -H \"x-officex-user-id: $UID\" -H \"x-officex-master-key: $KEY\" \\\n  -d '{\"wallet_id\":\"$APP_WALLET\",\"amount\":500,\"destination\":{\"type\":\"stripe\",\"account_id\":\"acct_xxx\"}}'\n\nTracers (Cross-Thread Correlation)\n\nA tracer_id is a string (format: trc_{timestamp36}-{random}) that links causally-related threads and schedule runs. Use cases: schedule runs weekly → each run creates a thread → all threads share one tracer_id → frontend can render a unified timeline.\n\nWhere tracer_id appears:\n\nThreads: POST /users/me/chats body accepts tracer_id. GET /users/me/chats?tracer_id=trc_xxx filters by tracer. PATCH can set/clear it (null to remove).\nSchedules: POST /users/me/schedules body accepts tracer_id (auto-generated as trc_* if omitted). All schedule responses include it.\nSchedule Runs: Each run record includes tracer_id copied from the schedule.\nChat Stream: Body accepts tracer_id. Stream protocol emits tracer:{tracerId}\\n after t:{threadId}\\n.\n\nExample flow:\n\nCreate schedule → tracer_id: \"trc_abc123\" auto-generated\nSchedule runs → creates thread with tracer_id: \"trc_abc123\"\nQuery GET /users/me/chats?tracer_id=trc_abc123 → returns all threads from this schedule\nFAQ\n\nHow do I test without real money? Use vouchers via admin panel or test voucher codes.\n\nCan my app have multiple pricing tiers? Yes. Your app logic decides how many credits to reserve per action.\n\nWhat happens if my webhook is down? Webhooks are fire-and-forget. Handle gracefully by polling installation status or creating users on-the-fly from iframe params.\n\nCan I transfer credits between apps I own? No. Credits only flow through reservation/settlement. Each app wallet is independent.\n\nHow do I handle long-running jobs that fail? Reserve upfront, sip as work completes, cancel on failure (refunds all unsettled credits), send inbox message explaining what happened.\n\nHow does the AI chat agent interact with my app? The agent receives your documentation and context_prompt. If agent_context has credentials, the agent calls your API via http_request tool. Make sure docs include clear API instructions."
  },
  "trust": {
    "sourceLabel": "tencent",
    "provenanceUrl": "https://clawhub.ai/mevdragon/officex",
    "publisherUrl": "https://clawhub.ai/mevdragon/officex",
    "owner": "mevdragon",
    "version": "1.0.0",
    "license": null,
    "verificationStatus": "Indexed source record"
  },
  "links": {
    "detailUrl": "https://openagent3.xyz/skills/officex",
    "downloadUrl": "https://openagent3.xyz/downloads/officex",
    "agentUrl": "https://openagent3.xyz/skills/officex/agent",
    "manifestUrl": "https://openagent3.xyz/skills/officex/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/officex/agent.md"
  }
}