Skip to main content

Command Palette

Search for a command to run...

No fetch, No Storage: Shipping Inside a Sandboxed Host

Building a React app inside a sandboxed host with no fetch, XHR, or browser storage: SDK network proxies, platform adapters, async polling, idempotency, and local development.

Updated
13 min readView as Markdown
No fetch, No Storage: Shipping Inside a Sandboxed Host

The answer came back in writing, from the vendor, and it amounted to: yes, and you'll be building it yourself.

The question had been a simple one. The platform we build on has an agent feature. Administrators define agents with their own tool queries, their own context rules, their own everything. They're good, and business users had opinions about what they wanted from them.

On the web client, those agents work.

On the native app, which is where the actual users are, they don't. Not a bug, not a roadmap item with a date, just a capability that exists on one client and not the other. So we asked how to close the gap, expecting either a workaround or a wait.

What we got instead was the sentence at the top of this post. The only supported way to reach those agents from the native app is to write the client yourself, as an embedded content package, running inside the host's own sandbox. Not an integration. Not a plugin. A whole application, built under someone else's rules, on a surface designed for dashboards.

So that's what this is about. Not the agents. The sandbox.

TL;DR

A locked-down host blocked fetch, blocked XHR, and blocked every form of browser storage. Its own SDK shipped a generic HTTP method, which turned out to be the sanctioned way around its own restriction. Normalise the platform differences at exactly one file, treat the async execution API as a job queue rather than a request, and be generous with your timeout ceiling when you don't control the workload.

The bug worth reading about is the poll loop, which spent a while confidently reporting failure for things that were still working.

The gap

This shape is more common than it sounds. A platform ships several clients, they don't reach feature parity at the same time, and the capability you need lives on the one your users aren't holding.

10-capability-gap

Your options when that happens are usually: wait, tell users to switch clients, or build the missing surface yourself. Waiting had no date attached. Users were on tablets all day and weren't switching. So, build.

The catch is that "build it yourself" here doesn't mean a normal web app that calls an API. It means a bundle running inside the host's own embedded content sandbox, under the host's rules.

What the sandbox takes away

Three things, and none of them are negotiable.

No fetch, no XHR, no axios. Direct HTTP from embedded content is blocked. Not CSP-blocked in a way you can argue with, just unavailable.

No storage of any kind. No localStorage, no sessionStorage, no IndexedDB. Everything lives in memory for the lifetime of the view, or it round-trips through the host's own record API.

No web root. Content is served from inside a package, so every asset path has to be relative. One base: './' in the build config, which is trivial once you know and confusing for an afternoon if you don't.

For a chat interface, that second one is the sharp edge. Chat is a stateful, conversational thing and the obvious place to keep a thread is local storage. You can't. Either you accept that a thread dies when the view unmounts, or you serialise it into a host record and rehydrate on load. We did the second, but only after building the first and discovering how much people expect a chat to remember.

The escape hatch is in the box

Here's the part I'd want someone to tell me earlier.

The host's SDK, the one you're required to load anyway, exposes two methods that together undo the restriction:

host.getSessionId()   // → { sessionId, url, isSandbox }
host.request(config)  // → generic HTTP

That's it. request is a general-purpose HTTP method that takes a URL, a verb, headers, and a body. From its point of view the platform's own API is just another endpoint.

11-escape-hatch

This isn't a loophole and it isn't clever. It's the sanctioned path, and it exists for a reason worth internalising: a host that blocks network access still needs its own embedded content to reach the network. So it ships a proxy and routes everything through it, where it can be inspected, authenticated, and governed. The restriction isn't "no HTTP," it's "no HTTP we can't see."

If you're ever staring at a platform that seems to have walled off something fundamental, look for the method the platform uses itself. It's usually right there in the SDK, documented, under a name that doesn't sound like what you need.

The other half is authentication. getSessionId hands back a session token, and the documentation is slightly nervous about it. The token is described as intended for exchanging auth with external applications. Using it to call the platform's own API is a reasonable reading, but it isn't the stated purpose.

That went on the validation list as the first thing to prove. If it hadn't worked, everything downstream would have needed a proxy service and the shape of the whole project would have changed. It worked. But it was the right thing to check on day one rather than day twenty.

One file knows about platforms

The host runs on more than one platform, and the two wrap the response from request differently. One returns a string. One returns an object with the payload nested inside, under a key that varies.

There is exactly one correct way to handle that, and it's to normalise once:

async function rawRequest(path, method = 'GET', body = null) {
  const ctx = await getContext();

  const config = {
    url: `${ctx.baseUrl}/api/${ctx.apiVersion}${path}`,
    method,
    headers: {
      Authorization: ctx.sessionId,
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    timeout: 60,
    expect: 'text',
  };
  if (body) config.body = JSON.stringify(body);

  // one platform needs a string argument; stringifying is safe on both
  const raw = await host.request(JSON.stringify(config));

  // platforms wrap the payload differently. This is the only place that knows.
  const text = typeof raw === 'string'
    ? raw
    : raw.data ?? raw.body ?? raw.response ?? '';

  return JSON.parse(text);
}

Every other file in the codebase calls a wrapper around this one and has no idea more than one platform exists. That sounds obvious written down. It is obvious. It is also the thing that decays first, because the second time you hit a platform difference you're deep in a component and it's so much faster to add a conditional right there.

Two small things worth stealing from that function. JSON.stringify on the config is unconditional even though only one platform requires it, because a rule with no exceptions survives longer than a rule with one. And session handling sits just above it: cache the session, and on an invalid-session response, clear the cache and retry exactly once before surfacing an error. Once, not in a loop.

Execution is a job queue, not a request

The agent API doesn't answer your question. It accepts it.

POST /agents/execute
→ { status: 'IN_PROGRESS', executionId: '...' }

The actual output only exists later, on a separate status endpoint, once the work finishes. So the client has to hold an execution ID, poll, and render a thinking state in between.

Three details that carry real weight:

Idempotency is your job. Every execution carries a client-generated request ID and the platform rejects duplicates, including against past executions. Generate it per execution, not per session, and make it genuinely unique:

requestId: `chat_${Date.now()}_${crypto.randomUUID()}`

Threading is a token you carry. The first turn's response includes a chat ID. Pass it into subsequent executions and the platform keeps the conversation coherent. Drop it and every turn is a stranger.

Cancellation is two things. There's a cancel endpoint on the server, and there's an AbortController for your poll loop. A stop button needs both, or you get a UI that has stopped listening to work that hasn't stopped happening.

The poll loop was wrong, and it was wrong in an interesting way

Original settings: poll every 2.5 seconds, give up after 10 attempts. About 25 seconds.

Then it started reporting failures.

Not many, and not reproducibly, which is the worst kind. Some agent actions came back in two seconds. Some took fifteen. Checking the actual execution data, a few ran past thirty.

The obvious read is "your timeout was too short." That's true, and it's also the least interesting thing about it.

Here's the real problem. The loop looked like this:

for (let i = 0; i < maxAttempts; i++) {
  const status = await getStatus(executionId);
  if (status !== 'IN_PROGRESS') return status;
  await sleep(intervalMs);
}
throw new Error('Agent execution failed');

Read that last line again. When the loop runs out, it reports failure. But nothing failed. The server never said no. The agent was still working, and would have finished, and probably did finish, into a void where nobody was listening.

The client was reporting a server outcome it had never received.

12-poll-outcomes

A poll loop has three possible endings and most implementations only encode two:

  1. The work completed
  2. The work explicitly failed
  3. I stopped waiting

The third is a statement about the client, not the server, and collapsing it into the second means telling users something is broken when it isn't. Worse, it's unfalsifiable from the UI. The user sees "failed," retries, generates a new execution, and now there are two running.

The fix has two parts. Give up much later, with an increasing interval so you're not hammering the status endpoint for four minutes. And when you do give up, say what actually happened: this is taking longer than expected and is still running, not this failed.

Why the ceiling is generous

The number we landed on is 100 attempts with a growing interval. That is a lot, and it's deliberate.

This was built to be portable. Zip it, drop it into another org, configure the entry point, and it works. Which means the agents it will be talking to are ones I have never seen, built by administrators I have never met, doing work I cannot predict.

Someone smarter than me is going to build an agent that traverses four levels of related records and calls out to something slow. That agent will take a while. If my client gives up at twenty-five seconds because that was comfortable for the agents I tested against, their agent looks broken and the fault will look like theirs.

So the ceiling isn't tuned. It's generous on purpose, because I don't control the workload. That's not the same as being careful, and I'd rather be honest that 100 is a number I chose to be safely past anything I could imagine, not a bound I measured.

When you're shipping something other people will drop into environments you'll never see, the correct posture on timeouts is cowardice.

Developing outside the host

host doesn't exist on your laptop. It's injected by the platform's library at runtime, so a local dev server has nothing to call.

Same answer as every embedded-host project: shim it.

export function installHostMock() {
  if (window.host) return;   // real environment, do nothing

  window.host = {
    getSessionId: async () => ({
      sessionId: import.meta.env.VITE_DEV_SESSION,
      url: import.meta.env.VITE_DEV_BASE_URL,
      isSandbox: true,
    }),
    request: async (cfg) => {
      const c = typeof cfg === 'string' ? JSON.parse(cfg) : cfg;
      const r = await fetch(c.url, {
        method: c.method, headers: c.headers, body: c.body,
      });
      return { success: true, data: await r.text() };
    },
  };
}

Plus a dev-server proxy so local requests dodge CORS. The guard on the first line matters more than it looks: the mock must be a no-op inside the real host, because the day it isn't, you'll spend an hour wondering why production is reading your laptop's environment variables.

Worth saying: the mock is not a simulator. It gets you a working UI loop and honest network calls, and it will not reproduce the platform response-shape differences, the auth behaviour, or anything about the native runtime. Those only surface in the real thing, which is why the validation list existed before the UI did.

The validation list

Before any interface code, six things got proven:

  • The session token is accepted by the target API
  • All endpoints respond as documented, tested outside the sandbox first with plain HTTP
  • Actions are exposed to the API at all, since the platform hides ones not explicitly flagged
  • The authenticated user actually has permission to execute them
  • Cross-origin behaviour on the web client
  • The exact response shape from request on each platform, captured as real payloads

Two of those turned out to be non-issues. Cross-origin was fine, and the fallback proxy I'd sketched for it never got built. That's not wasted effort. It's a risk that got cheap to dismiss because it was checked early rather than discovered late.

The one that mattered was the last. Capturing the real envelopes from both platforms up front is what let the normalization live in one function instead of being reverse-engineered from bug reports over three weeks.

The general version: your riskiest assumption should be the first thing you test, and it is almost never the thing you're most excited to build.

What it turned into

The thing I didn't anticipate was what happened after the demo.

Business users are now looking at agents that were previously theoretical to them, on the device they actually use. And having seen it, they started proposing things. Could it do this. What about that. Some of those were straightforward, some needed new agents built on top of the platform's own, and several worked.

That loop is worth more than the client is. A chat window is not a hard thing to build. A chat window that puts a capability in front of the people who have opinions about it, on the device they're holding, turns those opinions into a queue of things worth building.

What carries over

Strip the vendor out and the transferable bits are short:

  1. When a platform blocks something fundamental, look for the method it uses itself. Constrained hosts ship their own escape hatch, because they need one too.
  2. Prove the auth path on day one. Everything downstream depends on it and it's the assumption most likely to be wrong.
  3. Normalise platform differences at exactly one file and let nothing above it know they exist.
  4. Treat async execution as a job queue. Idempotency keys, a thread token, cancellation on both ends.
  5. A poll loop has three endings, not two. "I stopped waiting" is not "it failed."
  6. Be a coward about timeouts when you don't control the workload.
  7. Mock the host for local development, and don't mistake the mock for the platform.

None of that is specific to any vendor. It's what working inside somebody else's sandbox looks like, and the sandboxes are multiplying.


I'm Ashish Kr Jha, a software engineer. I build things in production and then have to live with them, which is the part that actually teaches you something. More at ashishkrjha.dev.