Files
logos-tutorial/guide-intents-for-app-developers.md

8.8 KiB
Raw Permalink Blame History

Intents — a guide for app developers

Status: shipped in Logos Basecamp. The QML surface described here is frozen — logos.request, logos.respond, intentRequested, the six error codes and the payload rules will not change. The shell-side machinery around it (chooser, install suggestions) is deliberately replaceable and will.

Nothing is signed yet, so a provider's name and label are claims rather than identity. See "Known limitations" in logos-basecamp/docs/app-to-app-intents.md before depending on this.

Intents let one app ask another to do something, without knowing which app will answer. Chat doesn't know what a wallet is; it asks for "send funds" and the shell finds who can.

Applies to ui_qml apps. Core modules cannot participate in V1.


Using a capability

1. Declare it in metadata.json:

"uses": [ { "intent": "wallet.send", "cardinality": "single" } ]

2. Call it from QML:

logos.request("wallet.send",
    { chain_id: 1, to: address, amount_wei: "1000000000000000000" },
    function (res) {
        if (res.ok) showReceipt(res.data.tx_hash)
        else        showError(res.error)
    })

That's it. You never name an app, and you never learn which one answered.


Providing a capability

1. Declare it in metadata.json:

"provides": [ { "intent": "wallet.send" } ]

2. Handle it in QML:

Connections {
    target: logos
    function onIntentRequested(requestId, intent, params, requesterName) {
        if (intent !== "wallet.send") return

        confirmSheet.open(params, function (approved, txHash) {
            logos.respond(requestId, approved,
                          { tx_hash: txHash },
                          approved ? "" : "cancelled")
        })
    }
}

Your app is brought to the foreground automatically. Show the request, let the user decide, respond.

3. Optionally, say what you expect. This is how a caller finds out how to call you — and the shell enforces it, so a payload you declared unusable never reaches your handler:

"provides": [ {
    "intent": "wallet.send",
    "params": [
        { "name": "to",     "type": "string", "required": true },
        { "name": "amount", "type": "number", "required": true },
        { "name": "memo",   "type": "string", "required": false }
    ]
} ]

type is string · number · bool · object · array. A missing required field or a wrong type is refused with bad_request before dispatch. Fields you did not describe are passed through untouched, so adding one later does not break existing callers. No params at all means "undescribed" — not "takes nothing" — and nothing is checked.


The result

Always three keys, always all three present:

{ ok: true,  data: <anything>, error: ""        }
{ ok: false, data: undefined,  error: "<code>"  }
Code Means
not_declared you forgot uses
unavailable no provider, or you weren't allowed — deliberately indistinguishable
bad_request your params were rejected — retrying unchanged won't help
cancelled the user backed out
timeout the provider never answered
failed the provider reported a failure

bad_request vs failed is the difference between "you sent the wrong thing" and "the world didn't cooperate". Only the first is worth fixing on your side — look at the provider's provides[].params to see the shape it wants.

Both the shell and the provider can send bad_request, and you can't tell which did. The shell sends it when your params can't cross an app boundary at all (nested past 8 levels, a string over 64 KB, a function, a QML object). A provider sends it when the values are the wrong values. Making the two look identical is on purpose: if shell-rejections came back instantly, the delay alone would tell you whether a provider exists.


Naming

namespace.verb          e.g.  wallet.send · packages.show · sign.transaction

Two to four dot-separated segments. Lowercase letters, digits and _ only; each segment starts with a letter. 364 characters. logos.* is reserved for the shell.

Names are matched byte-exactly. Agree on one before you ship it — a name is a contract.


Six things that will trip you up

  1. request() returns nothing. You get the result in the callback, never a handle to the provider. This is deliberate — it's what keeps your app working when the platform changes underneath.

  2. The callback always fires asynchronously, even on immediate failure. Never assume it has already run by the next line.

  3. params and res.data are real JS objects, not JSON strings. res.data.tx_hash works; don't JSON.parse it.

    One exception worth knowing: arrays arrive array-like, not as native Arrays. A list that crosses an app boundary comes through as a Qt sequence — length, indexing, map, forEach and JSON.stringify all behave exactly as you expect, but Array.isArray() returns false, for every array and not just empty ones. Use the operations, not the type check; Array.from(x) gives you a real array if you need one.

  4. Send canonical data, never display strings. Send amount_wei, not "0.5 ETH". The provider re-derives what the user sees from the bytes it will act on — if your label and your payload disagree, the user sees the payload.

  5. Never ask for a secret on behalf of another app. If a passphrase is needed, the provider collects it on its own screen. Never put a passphrase in params.

  6. uses entries are objects, not strings. [{ "intent": "x" }], not ["x"]. cardinality accepts only "single" today.


Not possible yet

  • Core (non-UI) modules as requester or provider
  • Cancelling a request once sent
  • Anything with no user present — every intent assumes a screen
  • Choosing a provider yourself. If several apps offer a capability, the shell asks the user — you cannot influence or see that list

When several apps qualify

The shell raises a chooser. You get no say in it, and that is the point.

  • The list is drawn by the shell, with the same names and icons as the sidebar. You cannot influence how a provider appears, and a provider cannot dress itself up.
  • It is sorted, so the order is stable.
  • It is raised every time. There is no "always use this app" yet, so do not assume a repeat request will run without the user seeing a dialog.
  • Dismissing gives you cancelled, not unavailable — so you can tell "the user said no" from "there was nobody to ask". Treat it as a normal outcome, not an error to report.

The provider keeps the foreground. Once your request completes, the shell does not navigate back to you. Returning is something the user does. Do not write your app assuming it regains focus.


Being found before you are installed

provides is copied into your .lgx manifest at bundle time, and from there into the catalog index. That is what lets a catalog answer "which installable package provides this?" — the shell's own registry only sees packages already on disk.

Intent names only. uses stays in metadata.json, because a catalog needs to know what a package can do, not what it wants to call — and so does your params, because the shell enforces that against the installed file, not the catalog's copy. Entries are objects ({"intent": ...}), and a bare string is normalised up to that form.

provides sits inside the manifest, which is the region a package signature covers — so once signing exists your capability claim is attested rather than merely asserted. Nothing is signed today: the published catalog carries no signatures and the shell's trustedSigners list is empty, so right now the claim is exactly a loose text file that anyone can write.

What the caller sees when you are not installed: unavailable, on the usual timing floor — the same answer as if no such package existed anywhere. The shell may separately offer the user your package, but that offer is not part of their request and never completes it. If they install you, they retry; your first request from them is a fresh one. An app cannot tell "not installed" from "nothing exists", by design.


Design rules behind this

Worth knowing, because they explain the constraints above:

  • You name a capability, never an app — so installing a second wallet never means changing chat.
  • The shell draws the chooser — a requester-drawn picker could hide a rival or fake an entry.
  • The provider owns its own UI and its own secrets — nothing sensitive crosses between apps.
  • Errors are vague on purpose — "no such provider" and "you were denied" must look identical.

Reference: logos-developer-guide.md §8.5 for the full API surface and the metadata.json field reference.