The product everyone wants — a real on-phone assistant that listens, sees, and acts — is finally buildable. agentlib makes the wiring straightforward.

What we’re building

A demo “pocket assistant” agent that can:

  1. Listen to a spoken command.
  2. See the user’s screen if context is needed.
  3. Act by driving other apps through accessibility / AppIntents.
  4. Speak the result back.

All on-device where possible. Cloud as a fallback. Same code on iPhone and Pixel.

The CLIs

agentlib ships first-party CLIs for each pillar:

  • /bin/voice.listen — start STT, return transcript.
  • /bin/voice.speak — TTS, plays back text.
  • /bin/vision.describe — describe an image or current screen.
  • /bin/vision.ocr — extract text from an image.
  • /bin/apps.list — list installed apps the assistant can drive.
  • /bin/apps.<bundle>.<action> — perform an action in another app (accessibility-driven on Android, AppIntents/SiriKit on iOS).

The orchestrator

final assistant = AgentSpec(
  name: 'pocket',
  instructions: '''
You are a private on-device assistant. When the user speaks:
1. Transcribe with `/bin/voice.listen`.
2. Understand the intent.
3. If you need to see the screen, call `/bin/vision.describe`.
4. Carry out the request through `/bin/apps.*` or other CLIs.
5. Speak the result with `/bin/voice.speak`.

Prefer on-device tools. Ask consent before any /apps.* action.
''',
  model: ModelRoute.preferOnDevice(
    onDevice: [FoundationModelsProvider(), GeminiNanoProvider(), FllamaProvider(llm: a, modelName: 'Qwen3-1.7B-Instruct')],
    fallback: AnthropicProvider(apiKey: '<KEY>'),
  ),
  tools: [...virtualFilesystemTools(), shTool, voiceTool, visionTool, appsTool],
);

A turn end-to-end

User: “Tell my partner I’ll be late.”

Inside agentlib, the model emits an Sh pipeline:

Sh("voice listen | jq -r .text > /scratch/intent.txt")
// → /scratch/intent.txt now contains "Tell my partner I'll be late."

Then the model interprets intent and dispatches:

Sh("contacts find 'partner' | jq -r .number | xargs whatsapp send --message 'Running late, see you soon'")

The PermissionRequestHook surfaces a consent sheet for WhatsApp. The user confirms. agentlib snapshots before dispatch (because whatsapp.send is highImpact: true). The send happens. The model emits:

Sh("voice speak --text 'Done, told them you\\'re running late.'")

Everything on-device, except the consent dialog. One Flutter app. Five tool calls.

Vision when needed

User: “What’s on the screen right now?”

Sh("vision describe --screen | jq -r .summary")
// → "A QR code with the URL https://example.com/event"

/bin/vision.describe --screen captures the current foreground screen (iOS Screen Capture API or Android MediaProjection — both require one-time consent), runs it through Foundation Models’ vision capability on iOS 19+ or through Google ML Kit + an LLM caption pass on Android, and returns a structured description.

For dense text, /bin/vision.ocr runs Apple Vision or ML Kit OCR and returns string output. The model can then run regex / jq over it.

Driving other apps

Through Android’s accessibility service or iOS’s AppIntents + SiriKit, the assistant can perform actions in other apps. agentlib normalises this behind /bin/apps.*:

Sh("apps gmail compose --to 'partner@example.com' --subject 'Late' --body 'Be there in 30'")

This works because agentlib registers a per-app CLI that wraps the appropriate native API. On Android, that’s an accessibility-service walk; on iOS, that’s an AppIntent invocation. The model sees one consistent CLI.

Battery + privacy

Wire the lifecycle hooks once and forget about them:

hooks
  ..register(OnLowBattery((e, _) async {
    if (e.percent < 15) {
      return HookOutcome.replace(modelOverride: FllamaProvider(...tiny));
    }
    return HookOutcome.carryOn();
  }))
  ..register(PermissionRequestHook((req, ctx) async {
    final ok = await showConsent(ctx, req);
    return ok ? HookOutcome.carryOn() : HookOutcome.deny('user denied');
  }))
  ..register(PostToolUse((call, result, ctx) async {
    await audit.log(call: call, result: result);
    return HookOutcome.carryOn();
  }));

What it feels like to use

Latency is the main concern. End-to-end target on an iPhone 15 Pro: ~1.5 s from end-of-speech to start-of-TTS for a simple “send message” intent. That’s two on-device model calls (intent classification + planning) plus a native API dispatch. Tight enough to feel snappy.

Patterns to steal

Audio routing. Use flutter_sound or record to capture audio. Pass the buffer to /bin/voice.listen rather than recording inside the CLI — the CLI shouldn’t own the mic stream.

TTS interruption. Wire a “user is speaking again” hook that cancels in-flight TTS. Otherwise the assistant will talk over the user.

Wake-word. Optional but nice: ship a tiny on-device wake-word model (e.g. Porcupine) that triggers voice.listen only when called. Saves battery and respects the no-always-listening promise.

  • On-device routing — the model side of the equation.
  • 21 hooks — the consent + audit primitives this article relies on.
  • Install — get the CLIs wired in 60 seconds.