Skip to content

Troubleshooting

Whenever something behaves unexpectedly, run:

Terminal window
linkworld doctor

It 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.

Run the scaffold smoke test from the repo root:

Terminal window
python3 scripts/test-scaffold-builds.py

It 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 cleanly

If 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”
  1. Check linkworld login succeeded — ~/.config/linkworld/config.json should have a token field.
  2. Hit https://linkworld.ai/api/dev/me with the token — should return your dev account.
  3. If you used linkworld init, you also need linkworld deploy at 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: BadName should be bad_name
  • Top-level field misspelled: runtimeImage: instead of runtime: { image: ... }
  • apiVersion not exactly linkworld.ai/v2
  • runtime.port outside 1024–65535

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+exit
exc.decisionWhat it meansWhere to fix
scope_deniedTool 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_requiredTenant’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_gatedTool 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_unavailablePartner-app container unreachable (image not pushed, health check failing).linkworld doctor — verify docker, GHCR, image push.
yankedThe app version installed for this tenant was yanked.Bump version in manifest, deploy, run linkworld upgrade <slug>.
network_error / platform_errorTransient — 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.

Walk the lookup chain:

  1. Did you linkworld secrets set KEY=... for the dev-default? Check linkworld secrets list --app <slug>.
  2. The key must match ^[A-Z][A-Z0-9_]{0,63}$. Lowercase or spaces fail silently — the SDK validates client-side.
  3. Network error reaching the platform also returns null (fail-closed). Check container logs for the underlying error.

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 CSP
const r = await fetch('/outlets')
// Right — proxied through the parent shell
import { 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:

ValueBehaviour
"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 through
await 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.

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 inject LINKWORLD_MCP_URL / LINKWORLD_MCP_TOKEN. Probably a platform-side bug — file an issue.
  • Health check fails: your runtime.port doesn’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_secret rotated (very rare; coordinate with admin).

Sign out, sign in again. If the new token also fails, contact platform support.

  • LINKWORLD_LOCAL=1 is required (or app.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_send and you haven’t POST /__mock/tool for it yet, you get ToolCallError(decision='not_found').
  • The /__mock/secret endpoint sets in-process state; restart the app and you start fresh.
  • 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.

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:

Terminal window
# Pre-pull the image yourself first
docker pull ghcr.io/you/my-app:0.1.0
linkworld deploy --skip-build --version 0.1.0

If it still times out, check that the image is public on GHCR or that the platform’s pull token has access.