Skip to main content

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.

Loading chatbot...

URI Shape

sandbox://<sandboxName>/<action>?<query>

Three actions are supported today:

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

Both card kinds share one auto-reveal flag

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"
/>
The three handlers do not behave the same way

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 };
note

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.

An https:// defaultAction behaves exactly as before — interception happens only for sandbox://.

See Also