PowerSync AI Hackathon 2026 · Local-First & Offline-Capable AI
An on-device AI assistant for wildlife park rangers. All AI inference runs locally using Apple Foundation Models and SpeechAnalyzer — no connectivity required for intelligence features. Data syncs to Supabase via PowerSync when back in range.
https://github.com/Clemo97/askari-ai/raw/main/Videos/RPReplay_Final1776686584.MP4
Patrol map — live incident pins with colour-coded severity, park boundary overlay, and tap-to-view incident detail
Incident report — full detail view with category, GPS coordinates, field notes, and photo evidence
AI Intelligence dashboard — natural-language query powered by Apple Foundation Models with tool-called results from the local PowerSync SQLite DB
| Feature | Stack |
|---|---|
| Natural language patrol queries | Apple Foundation Models + tool calling (local SQLite) |
| Voice incident note dictation (STT) | AVAudioRecorder + SpeechAnalyzer / SpeechTranscriber |
| Pre-patrol AI briefing | Apple Foundation Models + ephemeral session |
| Offline-first sync | PowerSync Sync Streams → Supabase |
| Reactive UI | SwiftUI + The Composable Architecture |
AskariAI/
├── App/ # App entry point, root navigation
├── Managers/ # SystemManager, SupabaseConnector, Schema
│ ├── Schema.swift # PowerSync local SQLite schema
│ ├── SystemManager.swift # PowerSync database + sync stream subscriptions + TCA dependency
│ ├── SupabaseConnector.swift
│ └── _Secrets.swift # ← gitignored, copy from template
├── Features/ # TCA Reducers + Views (co-located)
│ ├── App/ # AppFeature, MainFeature
│ ├── Auth/ # AuthFeature
│ ├── Missions/ # MissionsFeature
│ ├── ActiveMission/ # ActiveMissionFeature (GPS tracking)
│ ├── Dashboard/ # DashboardFeature (AI query + intel overview)
│ └── Incidents/ # LogIncidentFeature (voice dictation + media)
├── Models/ # Codable data models
├── Views/ # SwiftUI views
│ └── Copilot/ # CopilotChatView, AIBriefingView
└── AI/
├── AIManager.swift # Foundation Models sessions + SpeechAnalyzer lifecycle
└── Tools/
└── CactusTools.swift # Tool protocol implementations (DB query + incident logging)
supabase/
└── migrations/
├── 001_initial_schema.sql # Applied ✓
└── 002_rls_policies.sql # RLS policies
powersync/
└── sync-config.yaml # Sync Streams config (edition 3)
- Xcode 26+ (Swift 6 toolchain)
- iOS 26+ device — Apple Foundation Models and SpeechAnalyzer require a real device with Apple Intelligence enabled (simulator is not supported)
- Apple Intelligence enabled on the device (Settings → Apple Intelligence & Siri)
- A Supabase project
- A PowerSync Cloud instance linked to that Supabase project
git clone https://github.com/Clemo97/askari-ai.git
cd askari-ai
open AskariAI.xcodeproj # or .xcworkspace if presentSwift Package dependencies resolve automatically on first open. If they don't, go to File → Packages → Resolve Package Versions.
The app reads credentials from AskariAI/Managers/_Secrets.swift, which is gitignored. A template is provided at AskariAI/Managers/_Secrets.template.swift.
Copy the template:
cp AskariAI/Managers/_Secrets.template.swift AskariAI/Managers/_Secrets.swiftThen fill in _Secrets.swift:
enum Secrets {
static let supabaseURL = URL(string: "https://<your-project-ref>.supabase.co")!
static let supabaseAnonKey = "<your-supabase-anon-key>"
static let powerSyncEndpoint = "https://<your-instance-id>.powersync.journeyapps.com"
// Optional — set to your Supabase Storage bucket name to enable media attachments
static let supabaseStorageBucket: String? = "incident-media"
}You can find these values at:
- Supabase URL + anon key: Supabase Dashboard → Project Settings → API
- PowerSync endpoint: PowerSync Dashboard → Your Instance → Connection URL
The full database definition is split into focused files under supabase/:
| File | Purpose |
|---|---|
| supabase/schema.dbml | Schema documentation — tables, PKs, FKs, checks (DBML format) |
| supabase/schema.sql | CREATE TABLE statements + spot_types seed data |
| supabase/indexes.sql | Performance indexes |
| supabase/functions.sql | Helper functions (auth_staff_id, auth_staff_park_id, auth_staff_rank, trigger_set_updated_at, handle_new_user) |
| supabase/triggers.sql | updated_at triggers + commented on_auth_user_created auth trigger |
| supabase/rls.sql | RLS enables + all policies |
| supabase/powersync.sql | PowerSync scoping notes (not executed in DB) |
Open the Supabase SQL Editor (Dashboard → SQL Editor → New query) and run each file in the order listed. Each query window is a fresh paste — run them one at a time.
| Step | File | Action |
|---|---|---|
| 1 | supabase/schema.sql | Creates all tables; seeds 14 spot_types rows |
| 2 | supabase/indexes.sql | Adds performance indexes |
| 3 | supabase/functions.sql | Creates helper functions + handle_new_user |
| 4 | supabase/triggers.sql | Attaches updated_at triggers; see note for auth trigger |
| 5 | supabase/rls.sql | Enables RLS and applies all policies |
Auth trigger (step 4):
triggers.sqlincludes a commented-outCREATE TRIGGER on_auth_user_createdon theauth.userstable. Uncomment and run it in the Supabase SQL Editor (which executes as superuser). This enables automaticstaffrow creation on signup.
- Supabase Dashboard → Storage → Create bucket
- Name it
incident-media(or whatever you set in_Secrets.supabaseStorageBucket) - Set it to Private
- Add a storage policy allowing authenticated users to upload/download their own files
Sign up via the app — handle_new_user automatically creates a staff row linked to the first park in the database. To promote an account to admin:
UPDATE public.staff SET rank = 'admin' WHERE email = 'you@example.com';To assign a ranger to a specific park (if multiple parks exist):
UPDATE public.staff
SET park_id = (SELECT id FROM public.parks WHERE name = 'Nairobi National Park')
WHERE email = 'ranger@example.com';Follow the PowerSync Supabase integration guide to connect your PowerSync instance to your Supabase database.
npm install -g @powersync/cli
powersync login
powersync link cloud --project-id=<your-project-id>
powersync deploy sync-configThe config is at powersync/sync-config.yaml.
- Select your target device in Xcode (real device recommended for AI features)
- Product → Run (⌘R)
- Sign up with an email —
handle_new_usercreates astaffrow and assigns the default park automatically - To promote yourself to admin, run
UPDATE public.staff SET rank = 'admin' WHERE email = 'you@example.com';in the SQL Editor - Apple Foundation Models are built into the OS — no model download required for LLM features
- SpeechAnalyzer locale assets (~50–100 MB) download automatically on first dictation tap
| Package | URL | Version |
|---|---|---|
| PowerSync Swift SDK | https://github.com/powersync-ja/powersync-swift |
1.13.0 |
| Supabase Swift | https://github.com/supabase/supabase-swift |
>= 2.41.1 |
| TCA | https://github.com/pointfreeco/swift-composable-architecture |
>= 1.25.1 |
All packages are declared in Package.swift and resolve automatically in Xcode.
Note:
FoundationModelsandSpeech(SpeechAnalyzer) are Apple system frameworks — no SPM dependency required. They are available on iOS 26+.
- Supabase Project: (set
supabaseURLandsupabaseAnonKeyin_Secrets.swift) - PowerSync Instance: (set
powerSyncEndpointin_Secrets.swift) - Schema: see
supabase/schema.dbml
Migrated from sync rules (bucket_definitions) to a single consolidated sync stream (migrated_to_streams). CTE-style with: parameters resolve per-user via auth.user_id() — ranger and admin data are filtered server-side, no client-side parameters required.
config:
edition: 3
streams:
migrated_to_streams:
auto_subscribe: true
with:
ranger_own_features_param: >-
SELECT staff.id AS staff_id FROM staff
WHERE staff.user_id = auth.user_id()
AND staff.rank = 'ranger' AND staff.is_active = TRUE
ranger_park_data_param: >-
SELECT staff.park_id AS park_id FROM staff
WHERE staff.user_id = auth.user_id()
AND staff.rank = 'ranger' AND staff.is_active = TRUE
admin_map_features_param: >-
SELECT staff.park_id AS park_id FROM staff
WHERE staff.user_id = auth.user_id()
AND staff.rank = 'admin' AND staff.is_active = TRUE
queries:
# Ranger: own incidents
- "SELECT map_features.* FROM map_features, ranger_own_features_param AS bucket WHERE map_features.created_by = bucket.staff_id"
# Ranger: park context
- "SELECT parks.* FROM parks, ranger_park_data_param AS bucket WHERE parks.id = bucket.park_id"
- "SELECT park_boundaries.* FROM park_boundaries, ranger_park_data_param AS bucket WHERE park_boundaries.park_id = bucket.park_id"
- "SELECT staff.* FROM staff, ranger_park_data_param AS bucket WHERE staff.park_id = bucket.park_id AND staff.is_active = TRUE"
# Admin: all park map features + context
- "SELECT map_features.* FROM map_features, admin_map_features_param AS bucket WHERE map_features.park_id = bucket.park_id"
- "SELECT parks.* FROM parks, admin_map_features_param AS bucket WHERE parks.id = bucket.park_id"
- "SELECT park_boundaries.* FROM park_boundaries, admin_map_features_param AS bucket WHERE park_boundaries.park_id = bucket.park_id"
- "SELECT staff.* FROM staff, admin_map_features_param AS bucket WHERE staff.park_id = bucket.park_id AND staff.is_active = TRUE"
# Global reference (all users)
- SELECT * FROM spot_types WHERE spot_types.is_active = TRUE
- SELECT * FROM mission_role_typesOn the client, SystemManager.connect() subscribes to the single stream and awaits its first sync:
let sub = try await db.syncStream(name: "migrated_to_streams", params: nil).subscribe()
try await sub.waitForFirstSync()auto_subscribe: true means the server begins syncing immediately on connection — the explicit subscribe call is used only to obtain a handle for waitForFirstSync(), which blocks until the local SQLite database has received its initial data.
All AI features use Apple system frameworks — no third-party model hosting, no network calls for inference.
| Feature | Session type | Notes |
|---|---|---|
| Ranger Copilot chat | LanguageModelSession (persistent) |
Multi-turn; retains transcript |
| Pre-patrol briefing | LanguageModelSession (ephemeral) |
Fresh session per briefing |
| Dashboard queries | LanguageModelSession (ephemeral) |
Fresh session per query |
- Availability checked at runtime via
SystemLanguageModel.default.isAvailable - Tool calling via the
Toolprotocol:QueryRecentIncidentsTool,GetRangerStatsTool,LogIncidentTool— each executes a PowerSync SQLite query and returns structured JSON to the model - No model download required — Apple Intelligence is part of the OS
Voice note dictation uses SpeechAnalyzer + SpeechTranscriber (iOS 26 Speech framework), following the WWDC25 session 277 reference architecture.
| Component | Role |
|---|---|
AVAudioRecorder |
Captures 16 kHz mono PCM WAV to a temp file |
SpeechAnalyzer |
Orchestrates transcription pipeline |
SpeechTranscriber |
On-device locale model; options-based init with .volatileResults |
AssetInventory |
Downloads locale model assets on first use (~50–100 MB); no-op if already current |
STTBufferConverter |
Resamples AVAudioPCMBuffer for live streaming; primeMethod = .none avoids timestamp drift |
- User taps Dictate →
loadSTTIfNeeded()requests mic permission then ensures locale assets are installed viaAssetInventory AVAudioRecorderrecords to a 16 kHz mono PCM.wavtemp file (audio session:.playAndRecord/.spokenAudio)- User taps Stop → recorder stops, audio session deactivated
SpeechAnalyzer.start(inputAudioFile:finishAfterFile:true)processes the file autonomously;finalizeAndFinishThroughEndOfInput()flushes results- Only
isFinalresults are collected; transcription appended to the incident notes field; temp file deleted
Device requirement: Apple Foundation Models and SpeechAnalyzer require a physical device running iOS 26 with Apple Intelligence enabled. Neither works in Simulator.