sandbox:// Handoff Cards
When the agent has opened a browser session inside a sandbox, produced a file, or
assembled a whole directory, it needs a way to hand the user off to it. That is done by pointing an attachment
card's defaultAction at the custom sandbox:// scheme.
The SDK intercepts it before the open-in-a-new-tab fallback, resolves it into a
typed intent, and dispatches it to the matching handler. The raw sandbox:// URI is
never passed to window.open().
sandbox:// Handoff Cards
When an attachment card's defaultAction points at the custom sandbox:// scheme, the SDK intercepts it before the open-in-new-tab fallback, resolves it into a typed intent, and dispatches it to the matching handler. Plain https:// links are untouched and open normally.
<Chatbot
config={{ botProviderEndpoint: '...' }}
customChannelId="my-channel"
// intercepts sandbox://<name>/open-browser
onSandboxOpenBrowser={(sandboxName) => { /* … */ }}
// intercepts sandbox://<name>/open-file?absolute_path=…
onSandboxOpenFile={(sandboxName, absolutePath) => { /* … */ }}
// intercepts sandbox://<name>/open-folder?absolute_path=…
onSandboxOpenFolder={(sandboxName, absolutePath) => { /* … */ }}
/>Dispatched intents
No sandbox:// card clicked yet
Click any of the first three cards → the resolved intent is logged below. The fourth is a plain link and is never intercepted.
URI Shape
sandbox://<sandboxName>/<action>?<query>
Three actions are supported today:
| URI | Resolves to |
|---|---|
sandbox://sbx-a/open-browser | { kind: 'open-browser', sandboxName: 'sbx-a' } |
sandbox://sbx-a/open-file?absolute_path=%2Fhome%2Fuser%2Freport.md | { kind: 'open-file', sandboxName: 'sbx-a', absolutePath: '/home/user/report.md' } |
sandbox://sbx-a/open-folder?absolute_path=%2Fwork%2Foutput | { kind: 'open-folder', sandboxName: 'sbx-a', absolutePath: '/work/output' } |
absolute_path must be URL-encoded (absolute paths carry many /).
Why a Folder Needs Its Own Action
The two destinations are not interchangeable: a file goes into the viewer (its content is read, then a change watch is attached), while a folder only unfolds the tree down to that level and selects it — nothing read, nothing watched.
So why not reuse open-file and let the panel work it out? Because the panel has not listed that
level yet when the card arrives, so it cannot tell a file from a directory. "Try it as a file and
fall back to a folder" is not a workable substitute either — the backend answers 500 for fs/file
on a directory and drops the fs/watch connection. The destination therefore comes from the card
itself, which is also why RequestedFile.kind is required.
Whether an arriving card also pulls the built-in aside out is governed by autoRevealOnOpenFileCard
(default true) for both kinds. The OpenFileCard in the name predates the folder card and is kept
for compatibility — "may a card pull the panel out" is one question, not two.
Taking Over the Handling
All three actions can be overridden by the consumer:
<Chatbot
config={{ botProviderEndpoint: "..." }}
customChannelId="my-channel"
// When set, the SDK defers to you; otherwise it fetches a one-time URL and opens a tab
onSandboxOpenBrowser={(sandboxName) => { /* … */ }}
// open-file usually lands in the File Explorer viewer
onSandboxOpenFile={(sandboxName, absolutePath) => {
controller.requestFile(sandboxName, absolutePath);
}}
// open-folder lands on the file tree, not in the viewer
onSandboxOpenFolder={(sandboxName, absolutePath) => {
controller.requestFolder(sandboxName, absolutePath);
}}
// Required if you really mean to take over open-file — see below
fileExplorer="off"
// Where the default handler opens the tab; defaults to '_blank'
sandboxBrowserOpenTarget="_blank"
/>
onSandboxOpenBrowser is a genuine either/or: when set the SDK defers to you, and only falls back to
its own handler when you omit it.
onSandboxOpenFile and onSandboxOpenFolder are not. They are additive, not replacements — your
handler runs, but the built-in File Explorer aside is still revealed as
well, because that branch only checks the fileExplorer prop and never whether you supplied a
callback. And fileExplorer defaults to 'builtin'.
The result is that your own controller and the Chatbot's internal one both receive the same
requestFile or requestFolder. To actually take over, set fileExplorer to "off".
Resolving It Yourself
resolveSandboxUri can also be used standalone — for instance when you render the
cards yourself:
import { resolveSandboxUri } from "@asgard-js/core";
const intent = resolveSandboxUri(uri);
if (intent === null) {
// Not sandbox://, unknown action, or missing absolute_path
// → treat it as a plain card, and never window.open() the raw URI
}
The return type:
type SandboxUriIntent =
| { kind: "open-browser"; sandboxName: string }
| { kind: "open-file"; sandboxName: string; absolutePath: string }
| { kind: "open-folder"; sandboxName: string; absolutePath: string };
Parsing uses a hand-written regex rather than new URL(). Absolute paths carry many
/, and a non-special scheme's host casing is unreliable under the WHATWG URL parser;
the query is still handed to URLSearchParams, which decodes.
Plain Links Are Untouched
An https:// defaultAction behaves exactly as before — interception happens only
for sandbox://.
See Also
- File Explorer — where
open-fileandopen-folderland - Cold-start HUD — sandbox launch and
launchedSandboxes - Templates — the attachment card structure itself