Troubleshooting
First step: linkworld doctor
Section titled “First step: linkworld doctor”Whenever something behaves unexpectedly, run:
linkworld doctorIt runs the env-setup checks linkworld deploy would do anyway,
plus a few extras (docker daemon, GHCR auth, SDK install) — and
prints actionable fix-hints next to anything that’s not ✓:
┏━━━━┳━━━━━━━━━┳────────────────────────────────────────────┓┃ ┃ Check ┃ Result ┃┡━━━━╇━━━━━━━━━╇────────────────────────────────────────────┩│ ✓ │ login │ logged in as [email protected] ││ ✗ │ docker │ daemon not running ││ ! │ ghcr │ no ghcr.io credential in docker config ││ ✓ │ sdk │ linkworld-sdk 1.1.0 ││ ✓ │ project │ linkworld.app.yaml found │└────┴─────────┴────────────────────────────────────────────┘
fail docker: daemon not running Start Docker Desktop or `systemctl start docker`. Falls back to `linkworld deploy --skip-build` if you push images from CI instead. warn ghcr: no ghcr.io credential in docker config Run `echo $GITHUB_PAT | docker login ghcr.io -u <you> --password-stdin` before `linkworld deploy`. Skippable if you push from CI.Exit code: 1 if any check failed, 0 if all-pass or warnings only.
My scaffold doesn’t even build
Section titled “My scaffold doesn’t even build”Run the scaffold smoke test from the repo root:
python3 scripts/test-scaffold-builds.pyIt scaffolds every template (CLI + the four dx_scaffold_app
templates), runs docker build against each, and starts the
container for ~5s to make sure it doesn’t crash on import.
Output:
=== Linkworld scaffold-build smoke === ✓ CLI python-app ✓ dx blank ✓ dx email-triage ✓ dx report-publisher ✓ dx webhook-handler
all 5 scaffolds build + smoke-run cleanlyIf any scaffold fails here, it WILL fail for a fresh dev — file a bug; we run this in CI on every PR for exactly that reason. The script skips with a clear message (and exit 0) if Docker isn’t available, so it’s safe to run in any CI environment.
My app doesn’t show up in the dev console
Section titled “My app doesn’t show up in the dev console”- Check
linkworld loginsucceeded —~/.config/linkworld/config.jsonshould have atokenfield. - Hit
https://linkworld.ai/api/dev/mewith the token — should return your dev account. - If you used
linkworld init, you also needlinkworld deployat least once before the app appears under “Your apps”.
linkworld deploy says “manifest validation failed”
Section titled “linkworld deploy says “manifest validation failed””The CLI runs the same Pydantic schema as the platform. Read the error carefully — it points at the field. Common causes:
- Tool name not lowercase:
BadNameshould bebad_name - Top-level field misspelled:
runtimeImage:instead ofruntime: { image: ... } apiVersionnot exactlylinkworld.ai/v2runtime.portoutside 1024–65535
ctx.tools.call(...) raised ToolCallError
Section titled “ctx.tools.call(...) raised ToolCallError”Every ToolCallError carries a decision field plus — when the
platform recognized the failure mode — a fix_hint and a doc_link.
Branch on decision to recover gracefully:
try: result = await ctx.tools.call("email_search", received_after="-30m")except ToolCallError as exc: ctx.logger.warning( "email_search failed: %s", exc.reason, extra={"decision": exc.decision, "fix_hint": exc.fix_hint}, ) if exc.decision == "approval_required": # tenant must approve — try again next sweep tick return if exc.decision == "scope_denied": # the manifest is missing a scope. Surface a commitment so # the dev sees what to add. await ctx.commitments.add( title="Add scope to manifest", body=exc.fix_hint or exc.reason, ) return raise # genuinely unexpected — let the runtime log+exitexc.decision | What it means | Where to fix |
|---|---|---|
scope_denied | Tool gated by a scope your manifest doesn’t declare, OR the tenant hasn’t (re-)granted after a manifest change. | Add the scope to required_scopes, linkworld deploy, ask tenant to reactivate. |
approval_required | Tenant’s security gate is queueing this tool for a human approve in /inbox. | Catch + retry later, OR tell the tenant to set an action policy in workspace-control to auto-approve. |
denied (alias deny) | Security gate denied (policy says no, no approval offered). | Pick a less-privileged tool, or ask the tenant admin to adjust the policy. |
plan_gated | Tool requires a feature the tenant’s plan doesn’t include. | Upgrade plan, or pick an alternative tool — dx_get_skill_docs lists per-tool risk + plan gating. |
app_unavailable | Partner-app container unreachable (image not pushed, health check failing). | linkworld doctor — verify docker, GHCR, image push. |
yanked | The app version installed for this tenant was yanked. | Bump version in manifest, deploy, run linkworld upgrade <slug>. |
network_error / platform_error | Transient — platform side or transport. | Retry with backoff. |
The fix_hint is the imperative one-liner for THIS specific call (e.g.
“Add scope mail.read to required_scopes in linkworld.app.yaml, then
linkworld deploy and ask the tenant to reactivate my-bot”). The
doc_link points back at this page section.
ctx.secrets.get(...) returns null
Section titled “ctx.secrets.get(...) returns null”Walk the lookup chain:
- Did you
linkworld secrets set KEY=...for the dev-default? Checklinkworld secrets list --app <slug>. - The key must match
^[A-Z][A-Z0-9_]{0,63}$. Lowercase or spaces fail silently — the SDK validates client-side. - Network error reaching the platform also returns
null(fail-closed). Check container logs for the underlying error.
My handler isn’t firing
Section titled “My handler isn’t firing”For lifecycle hooks: check manifest.lifecycle.<hook>: true. The SDK
refuses to register a handler whose flag is false at construction time
— if app.run() started without complaint, the wiring is correct.
For on_inbound: the tenant has to opt into fan-out for your app
in their app-settings UI. Without that opt-in, the platform routes the
inbound to its standard agent and your app never sees it.
For schedules: the platform’s leader-elected scanner ticks every 60s.
If your cron is * * * * *, expect ±60s jitter. If you see no ticks
at all, check your cron syntax — 0 8 * * 1-5 runs Mon-Fri at 08:00,
not every 8 hours.
My bundle loads but fetch() calls do nothing
Section titled “My bundle loads but fetch() calls do nothing”The iframe runs at apps-cdn.linkworld.ai with CSP
connect-src 'none'. Direct fetch() (even to same-origin) is
silently blocked by the browser — no network request is ever made.
Symptoms: UI renders, but data lists are empty, KPIs stay at their loading state, “Save” buttons do nothing visible. DevTools Console shows no errors (the block is silent).
Fix: route all HTTP through the bridge:
// Wrong — blocked silently by CSPconst r = await fetch('/outlets')
// Right — proxied through the parent shellimport { LinkworldBridge } from './vendor/sdk-browser/index.js'const bridge = new LinkworldBridge({ onInit: () => loadAll() })const r = await bridge.callRoute('GET', '/outlets')See Building app UIs — Step 4
for the full pattern and the tools.call vs callRoute distinction.
My email draft is one wurscht — paragraphs disappear in Outlook
Section titled “My email draft is one wurscht — paragraphs disappear in Outlook”You drafted an email via email_draft_create / email_send /
email_reply / email_draft_reply and the recipient (or the
Outlook Drafts preview) shows your body as a single collapsed
paragraph, even though your code passed "para 1\n\npara 2".
Why this happens: the M365 / Graph integration posts every mail
body with contentType: HTML. The recipient’s HTML renderer
collapses the \n\n whitespace, killing your paragraph breaks.
The platform now auto-fixes this (Sept 2026): the
graph_email skill’s 4 send/draft tools accept a new
body_format parameter:
| Value | Behaviour |
|---|---|
"auto" (default) | Sniffs for HTML tags. If absent, converts \n\n to <p>...</p> and \n to <br>. Wraps the result in paragraph tags. |
"text" | Forces conversion regardless of input — useful if your body might contain < as a literal character. |
"html" | Trusts the caller verbatim. Use when you control HTML structure precisely. |
# Plain text — auto-converted to <p>...</p>await ctx.tools.call("email_draft_create", subject="…", body="Hello Jane,\n\nFirst paragraph.\n\nBest,\nMe", # body_format omitted → default "auto" detects plain text + converts)
# Already HTML — passes throughawait ctx.tools.call("email_draft_create", body="<p>Hello Jane,</p><p>First paragraph.</p>", body_format="html", # explicit, but "auto" would do the same)If you’re composing via an agent with structured output, the
EMAIL_COMPOSITION_SCHEMA preset + render_email_body_html()
helper from linkworld_sdk.composition give you per-paragraph
control upfront. The auto-conversion is a safety net for the common
case where the agent just emits text.
My bundle’s background is dark even though the tenant runs the light theme
Section titled “My bundle’s background is dark even though the tenant runs the light theme”You’re shipping hardcoded colors (background: #0a0e1a) in your
custom stylesheet instead of consuming the --lw-* tokens the
bridge injects on :root.
Fix: import lw-ui.css and use --_lw-bg / --_lw-text /
--_lw-border etc. in your CSS instead of hex literals:
/* Wrong — frozen dark, ignores tenant theme */body { background: #0a0e1a; color: #e6e9f2; }
/* Right — picks up the tenant's active palette */body { background: var(--_lw-bg); color: var(--_lw-text); }Even better: use the lw-* primitives from lw-ui.css and stop
writing styles for common components. See the
lw-ui catalog.
Container won’t start
Section titled “Container won’t start”Check the container logs in the dev console (Logs tab) or in your
local docker logs if running via linkworld deploy --skip-build on
a self-hosted VM:
platform credentials missing: the platform didn’t injectLINKWORLD_MCP_URL/LINKWORLD_MCP_TOKEN. Probably a platform-side bug — file an issue.- Health check fails: your
runtime.portdoesn’t match the port your handler binds to. Fix the manifest. - Image pull fails: the GHCR image isn’t public. Either make it public or coordinate with the platform admin to add pull credentials.
”OAuth completed but token was rejected”
Section titled “”OAuth completed but token was rejected””The OAuth flow worked but the JWT validation in /api/dev/me failed.
Most likely:
- The dev-account row was suspended after the JWT was minted.
- Wall-clock skew between the platform and the JWT issuer (rare; both are NTP-synced).
- The platform’s
supabase_jwt_secretrotated (very rare; coordinate with admin).
Sign out, sign in again. If the new token also fails, contact platform support.
Local mode acts weird
Section titled “Local mode acts weird”LINKWORLD_LOCAL=1is required (orapp.run({ local: true })). Without it the SDK tries to find platform credentials and refuses to start.- The mock tools have to be registered before they’re called. If a
handler calls
email_sendand you haven’tPOST /__mock/toolfor it yet, you getToolCallError(decision='not_found'). - The
/__mock/secretendpoint sets in-process state; restart the app and you start fresh.
My audit log is empty
Section titled “My audit log is empty”- Audit rows are written per tool call. If your handler doesn’t call any platform tools, it doesn’t produce audit rows.
- The Logs tab polls every 10s. Newly-arrived rows take up to that long to appear.
- In local mode, audit rows go to stdout, not the platform — they don’t show up in any dev console.
Deploy script keeps timing out
Section titled “Deploy script keeps timing out”The platform’s image registration call to GHCR can be slow on first publish (it pulls the image to verify it’s reachable). Default timeout is 60s. Workarounds:
# Pre-pull the image yourself firstdocker pull ghcr.io/you/my-app:0.1.0linkworld deploy --skip-build --version 0.1.0If it still times out, check that the image is public on GHCR or that the platform’s pull token has access.