Mobile apps don’t run all the time. The OS backgrounds them. Under memory pressure, it kills them. The user reopens the app two hours later expecting state to be there. An agent SDK built for a server doesn’t think about any of this. agentlib is built for the device, so it does.

Serialisable RunState

Every Runner exposes its state as a RunState value that can be serialised to JSON:

final state = runner.serializeState();
await persistedStore.write('agent.state', jsonEncode(state.toJson()));

// later, after a cold start:
final json = jsonDecode(await persistedStore.read('agent.state'));
final state = RunState.fromJson(json);
final runner = Runner.resume(state, config: ...);

RunState captures everything needed to continue: messages, tool history, scratch state, snapshot timeline pointers, the current step in the agent loop. It does not capture transient state like open HTTP connections — those reset on resume, which is what you want.

The lifecycle hooks

Three hooks cover the common transitions.

OnSuspend

Fires when the OS tells the app it’s about to be backgrounded. iOS gives you ~5 seconds; Android often more. Use it to persist state:

hooks.register(OnSuspend((event, ctx) async {
  final state = ctx.serializeState();
  await persistedStore.write('agent.state', jsonEncode(state.toJson()));
  return HookOutcome.carryOn();
}));

If the agent is mid-stream, OnSuspend waits for the current event to finish (a token, a tool result) and then fires. Long-running tool calls are checkpointed too.

OnResume

Fires when the app foregrounds again and the process is still alive. Refresh tokens, re-fetch any cached state that might be stale, re-establish HTTP/2 connections:

hooks.register(OnResume((event, ctx) async {
  await tokenStore.refreshIfNearExpiry();
  return HookOutcome.carryOn();
}));

Cold start

If the OS killed the process while backgrounded, there’s no OnResume — just a fresh launch. Check for persisted state at app boot:

Future<void> bootAgent() async {
  final raw = await persistedStore.read('agent.state');
  if (raw != null) {
    final state = RunState.fromJson(jsonDecode(raw));
    return Runner.resume(state, config: cfg);
  } else {
    return Runner.start(cfg);
  }
}

Push-resume

The killer feature for long-running work. The backend can send an APNs (iOS) or FCM (Android) push that carries a checkpoint payload. When the app receives the push, agentlib resumes from the checkpoint.

// Backend → device push payload:
//   {
//     "aps": { "alert": "..." },
//     "agentlib.resume": "<base64 checkpoint>"
//   }

// In the app's notification handler:
final payload = PushPayload.tryParse(notification.userInfo);
if (payload != null) {
  await Runner.resumeFromPush(payload, config: cfg);
}

This unlocks patterns like:

  • Server-side long ops finish → wake the device. A backend agent finishes a 5-minute report; pushes a resume to the device; the device’s agentlib instance picks up where it left off and shows the user the result.
  • Scheduled refresh. Every morning at 7 AM, the backend pushes a “refresh your daily summary” checkpoint.
  • Multi-device sync. User starts a conversation on iPhone, picks it up on iPad. The “pickup” is a push-resume from a snapshot synced to the backend.

Idempotent tools

When you resume, the agent might re-call a tool it had partially run before suspension. Mark idempotent tools:

final fetchWeather = tool(
  name: 'weather.get',
  description: 'Fetch the current weather.',
  schema: Schema.object({ 'location': Schema.string() }),
  idempotent: true,    // safe to re-call
  execute: (args, ctx) async { ... },
);

For non-idempotent tools (sending messages, charging cards), pair with snapshots — auto-snapshot before dispatch so revert is always available.

What this changes in your UI

Two patterns are worth wiring:

Show “resuming” briefly. On cold start, while you’re rehydrating state, show a small “picking up where you left off” indicator. It tells the user the conversation isn’t being lost.

Streaming reconnect. If the user was watching tokens stream when the app backgrounded, the rehydrated UI should show the final state of that turn (or continue streaming if the turn was incomplete). The RunEvent stream naturally replays from the resumed state.

Hooks for diagnostic visibility

Pair the lifecycle hooks with analytics:

hooks.register(OnSuspend((event, ctx) async {
  await analytics.log('agent.suspended', {
    'turn': ctx.turnIndex,
    'messages': ctx.messageCount,
    'inflight_tool': ctx.inflightTool,
  });
  return HookOutcome.carryOn();
}));

You’ll learn a lot from these numbers in the first week of production. Where do users tend to background mid-stream? Are there tool calls that frequently hang? The lifecycle data tells you.