agentleFS
cognitedatacognitedata/builder-skills/skills/handle-startup-state/SKILL.md

handle-startup-state

Skill6 starsChanged 9 days agoApache-2.0View on GitHub

MUST be used when a Flows/Fusion app needs to read state it was launched with — a deep link, a shareable URL, or startup/bootstrap arguments set by a host embedding the app (e.g. a Flows dashboard widget). Triggers: initialState, connectToHostApp initialState, startup arguments, bootstrap state, initial state, deep link, restore state on mount, syncInternalState, customAppInternalState.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 3 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    All three arrive through the same field.

    line 43
  2. Distinctive:

    Use the restored fields to drive initial render — don't restore into state and then immediately overwrite it with a default in a later …

    line 126
  3. Distinctive:

    Do this by default so the current URL always reflects what the user is looking at, and can be copied and shared.

    line 144
  4. Very specific:

    Keep the serialized state small.

    line 162
  5. Very specific:

    Test parseInitialState directly — no component mount, no mocking connectToHostApp:

    line 175

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Handle Startup State

Reads the optional initialState string returned by connectToHostApp() and restores it before first render, so the app opens directly into the right view instead of always starting from defaults.

Requires @cognite/app-sdk's connectToHostApp() handshake already wired up. If auth isn't wired up yet, run the setup-flows-auth skill first.

What initialState actually is

initialState is a single opaque string, present on the object returned by connectToHostApp():

const { api, initialState } = await connectToHostApp();

Your app never needs to know how it got there — treat it the same way regardless of source:

  • A user opened a shareable URL your app previously wrote via api.syncInternalState(...) (the customAppInternalState URL param).
  • A host embedded your app with a startup argument baked in (a Flows dashboard widget, or any other surface that programmatically launches your app with initial arguments).
  • The app was reloaded and the current URL still has the param from an earlier syncInternalState call.

All three arrive through the same field. Design your restore logic once and it covers all of them.

Step 1 — Decide your encoding

Pick one encoding and use it consistently:

  • Serialized route (a path string) — if your app's state maps cleanly onto a URL you'd otherwise navigate to.
  • JSON string — if you need multiple independent fields (active tab, filters, selected IDs). This is the more common choice and the one used below.

Step 2 — Read initialState on mount, defensively

Restore before the first meaningful render. Malformed state must never crash the app — the host cannot guarantee the string is well-formed (it never parses it either), and an older app version may have written a different shape.

import { useEffect, useRef, useState } from 'react';
import { connectToHostApp, type HostAppAPI } from '@cognite/app-sdk';

interface AppState {
  activeTab: string;
  selectedAssetId?: string;
  filters: { status: string };
}

const DEFAULT_STATE: AppState = {
  activeTab: 'overview',
  filters: { status: 'all' },
};

export function parseInitialState(initialState: string | undefined): AppState {
  if (!initialState) return DEFAULT_STATE;
  try {
    const parsed = JSON.parse(initialState) as Partial<AppState>;
    return { ...DEFAULT_STATE, ...parsed };
  } catch {
    // Malformed or from an incompatible app version — fall back to defaults.
    return DEFAULT_STATE;
  }
}

export function useAppState() {
  const [api, setApi] = useState<HostAppAPI | null>(null);
  const [state, setState] = useState<AppState>(DEFAULT_STATE);
  const apiRef = useRef<HostAppAPI | null>(null);

  useEffect(() => {
    connectToHostApp()
      .then(({ api: resolvedApi, initialState }) => {
        apiRef.current = resolvedApi;
        setApi(() => resolvedApi);
        setState(parseInitialState(initialState));
      })
      .catch(() => {
        // connectToHostApp rejects when there's no Fusion parent window to
        // connect to (e.g. opening the raw Vite dev URL directly instead of
        // through Fusion). api and state simply stay at their initial
        // values (null / DEFAULT_STATE) — nothing else to do here.
      });
  }, []);

  return { api, state, setState, apiRef };
}

Key points:

  • Merge, don't replace: { ...DEFAULT_STATE, ...parsed } so a partial or older-shaped payload still yields a usable state object, instead of undefined fields breaking downstream components.
  • try/catch around JSON.parse, always — this is the one part of this skill that is not optional.
  • Extract the parsing logic (parseInitialState) into a plain function so it's unit-testable without mounting a component or mocking connectToHostApp.

Step 3 — Wire restored state into your UI

Use the restored fields to drive initial render — don't restore into state and then immediately overwrite it with a default in a later effect.

function App() {
  const { api, state, setState } = useAppState();

  return (
    <TabBar
      activeTab={state.activeTab}
      onChange={(tab) => setState((s) => ({ ...s, activeTab: tab }))}
    />
  );
}

Step 4 — Sync state back with syncInternalState

Do this by default so the current URL always reflects what the user is looking at, and can be copied and shared. Skip it only if your app is launched exclusively with host-provided startup arguments and truly has no notion of a shareable link (rare — most apps benefit from this).

function updateState(patch: Partial<AppState>) {
  setState((prev) => {
    const next = { ...prev, ...patch };
    void apiRef.current?.syncInternalState(JSON.stringify(next));
    return next;
  });
}

syncInternalState replaces the stored value on every call — always pass the complete state object, not just the changed field.

Keep the serialized state small. It's URL-encoded into the customAppInternalState search param, and browsers cap total URL length at roughly 2,000 characters (varies by browser/server). As a rule of thumb, keep your serialized state under a few hundred characters: store IDs and view parameters (active tab, selected asset ID, filters), not full data objects or large blobs — re-fetch data from the API on mount using the restored IDs instead of persisting the data itself. See App state in URLs for the full write-side guide and the include/exclude table for what belongs in persisted state.

Step 5 — Add tests

Test parseInitialState directly — no component mount, no mocking connectToHostApp:

import { describe, expect, it } from 'vitest';
import { parseInitialState } from './use-app-state';

describe('parseInitialState', () => {
  it('returns defaults when initialState is undefined', () => {
    expect(parseInitialState(undefined)).toEqual({
      activeTab: 'overview',
      filters: { status: 'all' },
    });
  });

  it('merges a valid payload over the defaults', () => {
    const result = parseInitialState('{"activeTab":"trends"}');
    expect(result.activeTab).toBe('trends');
    expect(result.filters).toEqual({ status: 'all' });
  });

  it('falls back to defaults on malformed JSON', () => {
    expect(parseInitialState('not json')).toEqual({
      activeTab: 'overview',
      filters: { status: 'all' },
    });
  });
});

Checklist

  • initialState restored before first meaningful render, not in a later effect that races with default rendering
  • JSON.parse (or route-string parsing) wrapped in try/catch with a safe fallback — never lets malformed state crash the app
  • Parsed state is merged with defaults, not used as-is
  • Parsing logic extracted into a plain, unit-testable function
  • connectToHostApp().catch() handled — app still renders with defaults when run standalone (outside Fusion)
  • syncInternalState called with the full state object on every change (not just the changed field), unless there's a specific reason to skip syncing back
  • Serialized state kept small (IDs and view params, not full data objects) to stay well under the ~2,000-character URL length limit

Related

  • setup-flows-auth — prerequisite app-sdk connection.
  • integrate-fusion-agent — a registered agent resource can expose the same restored state to the Atlas sidebar agent.
  • App state in URLs — full public guide for the syncInternalState write side.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.