This page documents every way Char stores and processes your data. Nothing is hidden. Where possible, we include code snippets from the actual codebase so you can verify each claim yourself.
How Data Is Stored
All core data is stored locally on your device as plain Markdown and JSON files — not in a database. This is a deliberate choice. Files are portable, inspectable, and yours. You can open them in any text editor, back them up however you want, or drop them into an existing vault alongside tools like Obsidian. There is no proprietary format or opaque data layer between you and your data. For more on why we believe in this approach, see The Filesystem Is the Cortex.
Nothing leaves your machine unless you explicitly enable a cloud feature.
Directory Layout
Char uses two base directories. See Data for the full directory layout.
Global base (shared across stable and nightly builds):
models/stt/— downloaded speech-to-text model files (Whisper GGUF, Argmax tarballs)store.json— app state (onboarding status, pinned tabs, recently opened sessions, dismissed toasts, analytics preference, auth tokens)hyprnote.json— vault configuration (custom vault path if set)search/— Tantivy full-text search index
On macOS, this is typically ~/Library/Application Support/hyprnote/. On Linux, ~/.local/share/hyprnote/.
The store.json keys are defined by this enum — nothing else is persisted through the Tauri store:
4 | pub enum StoreKey { |
5 | OnboardingNeeded2, |
6 | DismissedToasts, |
7 | OnboardingLocal, |
8 | TinybaseValues, |
9 | PinnedTabs, |
10 | RecentlyOpenedSessions, |
11 | } |
Vault base (customizable, defaults to the global base):
sessions/— one subdirectory per session containing recorded audio, transcripts, notes, and attachmentshumans/— contact and participant dataorganizations/— organization datachats/— chat conversation dataprompts/— custom prompt templatessettings.json— your app settings
Application logs are stored in the system app log directory as rotating files (app.log, app.log.1, etc.).
Session Files
Each session gets its own subdirectory. Here is how Char loads session content from disk — this shows exactly what files exist per session:
10 | pub fn load_session_content(session_id: &str, session_dir: &std::path::Path) -> SessionContentData { |
11 | let mut content = SessionContentData { |
12 | session_id: session_id.to_string(), |
13 | meta: None, |
14 | raw_memo_tiptap_json: None, |
15 | transcript: None, |
16 | notes: vec![], |
17 | }; |
18 | |
19 | let entries = match std::fs::read_dir(session_dir) { |
Transcript data uses this structure — word-level timestamps, speaker channels, and optional speaker hints:
83 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] |
84 | #[serde(rename_all = "camelCase")] |
85 | pub struct TranscriptWord { |
86 | pub id: Option<String>, |
87 | pub text: String, |
88 | pub start_ms: i64, |
89 | pub end_ms: i64, |
90 | pub channel: i64, |
91 | } |
92 |
Audio Recording
When you start a recording session, Char spawns three actors in parallel:
- SourceActor — captures audio from your microphone and system speaker
- RecorderActor — writes audio samples to local WAV files
- ListenerActor — streams audio to your configured STT provider (cloud or local)
Here is the session supervisor that orchestrates these actors:
56 | async fn pre_start( |
57 | &self, |
58 | myself: ActorRef<Self::Msg>, |
59 | ctx: Self::Arguments, |
60 | ) -> Result<Self::State, ActorProcessingErr> { |
61 | let session_id = ctx.params.session_id.clone(); |
62 | let span = session_span(&session_id); |
63 | |
64 | async { |
65 | let (source_ref, _) = Actor::spawn_linked( |
Audio is written to WAV files on your local disk. Here is the recorder handling incoming audio samples:
130 | async fn handle( |
131 | &self, |
132 | _myself: ActorRef<Self::Msg>, |
133 | msg: Self::Msg, |
134 | st: &mut Self::State, |
135 | ) -> Result<(), ActorProcessingErr> { |
136 | match msg { |
137 | RecMsg::AudioSingle(samples) => { |
138 | if let Some(ref mut writer) = st.writer { |
139 | if st.is_stereo { |
Audio files are stored at {vault}/sessions/{session_id}/audio.wav — they never leave your device unless you explicitly use cloud transcription.
Encryption
Char stores data as plain Markdown and JSON files on disk — formats you can read, move, and version-control yourself. Char does not currently add its own encryption layer. Your data is protected by your operating system's file permissions and any full-disk encryption you have enabled (such as FileVault on macOS or LUKS on Linux).
We are actively investigating end-to-end encryption (E2EE) to add an additional layer of protection. This would encrypt your data at rest so that only you can decrypt it, independent of OS-level encryption. We do not have a timeline yet, but it is a priority for us.
Cloud Sync (Optional)
Char supports optional cloud database sync.
When enabled: Your session data can be synced to a remote database. This is only active if you explicitly configure a cloud database connection.
When not configured: All data stays in local Markdown and JSON files on disk.
How Data Is Processed
The following sections document every case where Char sends data to an external server. If a feature is not listed here, it does not send data externally.
Speech-to-Text
Char supports both local and cloud transcription.
Local models run entirely on your device — your audio never leaves your machine:
- Whisper models (QuantizedTiny, Base, Small, LargeTurbo) — downloaded as GGUF files
- Argmax models (ParakeetV2, ParakeetV3, WhisperLargeV3) — downloaded as tarballs
For local model details and download instructions, see Local Models.
Cloud models send your audio to the selected provider for processing. Here is how the listener actor connects to your configured STT provider:
22 | pub(super) async fn spawn_rx_task( |
23 | args: ListenerArgs, |
24 | myself: ActorRef<ListenerMsg>, |
25 | ) -> Result< |
26 | ( |
27 | ChannelSender, |
28 | tokio::task::JoinHandle<()>, |
29 | tokio::sync::oneshot::Sender<()>, |
30 | String, |
31 | ), |
The ListenerArgs passed to the STT adapter contain the following — this is all the data sent to the provider along with your audio stream:
122 | ListenerArgs { |
123 | app: state.ctx.app.clone(), |
124 | languages: state.ctx.params.languages.clone(), |
125 | onboarding: state.ctx.params.onboarding, |
126 | model: state.ctx.params.model.clone(), |
127 | base_url: state.ctx.params.base_url.clone(), |
128 | api_key: state.ctx.params.api_key.clone(), |
129 | keywords: state.ctx.params.keywords.clone(), |
130 | mode, |
131 | session_started_at: state.ctx.started_at_instant, |
What is sent:
- Your recorded audio (streamed in real-time or sent as a file for batch transcription)
- Configured language preferences and keywords
- The model name and API key for your provider
Where it goes depends on your setup:
- Pro curated models: Audio is proxied through
pro.hyprnote.com(our server) and forwarded to a curated STT provider. The proxy does not store your audio. - BYOK (Bring Your Own Key): Audio is sent directly from your device to the provider you selected.
Supported cloud STT providers:
| Provider | Privacy Policy |
|---|---|
| Deepgram | Privacy Policy |
| AssemblyAI | Privacy Policy |
| Soniox | Privacy Policy |
| Gladia | Privacy Policy |
| OpenAI | Privacy Policy |
| ElevenLabs | Privacy Policy |
| DashScope | Privacy Policy |
| Mistral | Privacy Policy |
| Fireworks AI | Privacy Policy |
Large Language Models
Char uses LLMs for summaries, enhanced notes, and chat. You can use cloud providers, bring your own key, or run models locally.
Pro Curated Models — Subscribe to Pro for curated cloud AI models that work out of the box.
BYOK (Bring Your Own Key) — Enter your own API key for OpenAI, Anthropic, Google, or Mistral.
Local Models — Run models locally using LM Studio or Ollama. See Local LLM Setup. Recommended: Gemma (Google) and Qwen (Alibaba).
When using cloud-based AI features, your session content is sent to the selected LLM provider. When using local LLMs, everything stays on your device.
Here is how the language model client is created — each provider connects directly to its own API:
230 | const createLanguageModel = (conn: LLMConnectionInfo): LanguageModelV3 => { |
231 | switch (conn.providerId) { |
232 | case "hyprnote": { |
233 | const provider = createOpenRouter({ |
234 | fetch: tracedFetch, |
235 | baseURL: conn.baseUrl, |
236 | apiKey: conn.apiKey, |
237 | }); |
238 | return wrapWithThinkingMiddleware(provider.chat(conn.modelId)); |
239 | } |
When auto-enhance runs after a session ends, the enhanced result is stored locally:
82 | const handleEnhanceSuccess = useCallback( |
83 | (text: string) => { |
84 | const noteId = currentNoteIdRef.current; |
85 | if (!text || !store || !noteId) return; |
86 | |
87 | try { |
88 | const jsonContent = md2json(text); |
89 | store.setPartialRow("enhanced_notes", noteId, { |
90 | content: JSON.stringify(jsonContent), |
91 | }); |
An analytics event is also fired when auto-enhance runs — it includes only the provider and model name, not the content:
161 | void analyticsCommands.event({ |
162 | event: "note_enhanced", |
163 | is_auto: true, |
164 | llm_provider: llmConn?.providerId, |
165 | llm_model: llmConn?.modelId, |
166 | }); |
What is sent:
- Transcript text, raw notes, and prompt templates
- The content you are asking the AI to process
Where it goes depends on your setup:
- Pro curated models: Requests are proxied through
pro.hyprnote.comand forwarded to a curated LLM provider. Nothing is stored by our proxy. - BYOK providers: Requests are sent directly to the provider you selected (OpenAI, Anthropic, Google, or Mistral).
- Local LLMs: Everything stays on your device. See Local LLM Setup.
MCP Tools (Pro Only)
Pro users have access to MCP tools for web search and URL reading during AI-assisted note generation.
What is sent:
- Search queries (for web search)
- URLs (for content extraction)
Where it goes:
Analytics (Opt-Out Available)
Char collects anonymous usage analytics by default to help improve the product. You can disable this entirely in Settings.
Here is the opt-out check — when disabled, the function returns immediately without sending anything:
10 | pub async fn event( |
11 | &self, |
12 | mut payload: hypr_analytics::AnalyticsPayload, |
13 | ) -> Result<(), crate::Error> { |
14 | Self::enrich_payload(self.manager, &mut payload); |
15 | |
16 | if self.is_disabled().unwrap_or(true) { |
17 | return Ok(()); |
18 | } |
19 |
What is attached to every analytics event — this is the complete enrichment logic:
45 | fn enrich_payload(manager: &M, payload: &mut hypr_analytics::AnalyticsPayload) { |
46 | let app_version = env!("APP_VERSION"); |
47 | let app_identifier = manager.config().identifier.clone(); |
48 | let git_hash = manager.misc().get_git_hash(); |
49 | let bundle_id = manager.config().identifier.clone(); |
50 | |
51 | payload |
52 | .props |
53 | .entry("app_version".into()) |
54 | .or_insert(app_version.into()); |
What is sent:
- Event names (e.g. "session_started", "transcription_completed")
- App version, build identifier, git hash, bundle ID
- A device fingerprint (a hashed machine identifier via
hypr_host::fingerprint(), not your name, email, or IP) - When signed in: your user ID and email (for account-linked analytics)
Where it goes:
- PostHog — product analytics. Privacy Policy
- Outlit — product analytics
The analytics client sends events to both services:
48 | impl AnalyticsClient { |
49 | pub async fn event( |
50 | &self, |
51 | distinct_id: impl Into<String>, |
52 | payload: AnalyticsPayload, |
53 | ) -> Result<(), Error> { |
54 | let distinct_id = distinct_id.into(); |
55 | |
56 | if let Some(posthog) = &self.posthog { |
57 | posthog |
How to disable: Go to Settings and turn off analytics. When disabled, no analytics events are sent — the is_disabled() check short-circuits the entire flow.
Error Reporting (Sentry)
Char uses Sentry for crash reporting and error tracking in release builds. Here is the complete initialization:
26 | let sentry_client = { |
27 | let dsn = option_env!("SENTRY_DSN"); |
28 | |
29 | if let Some(dsn) = dsn { |
30 | let release = |
31 | option_env!("APP_VERSION").map(|v| format!("hyprnote-desktop@{}", v).into()); |
32 | |
33 | let client = sentry::init(( |
34 | dsn, |
35 | sentry::ClientOptions { |
What is sent:
- Error messages, stack traces, and crash dumps
- A device fingerprint (hashed machine ID via
hypr_host::fingerprint()) — no name, email, or IP - App version and platform information (
hyprnote-desktop@{version}) - The tag
service: "hyprnote-desktop" auto_session_trackingis explicitly set tofalse
Where it goes:
- Sentry — error monitoring. Privacy Policy
Network Connectivity Check
Char periodically checks if your device is online. Here is the complete implementation:
7 | const CHECK_INTERVAL: Duration = Duration::from_secs(2); |
8 | const CHECK_URL: &str = "https://www.google.com/generate_204"; |
9 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); |
83 | async fn check_network() -> bool { |
84 | let client = reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build(); |
85 | |
86 | let client = match client { |
87 | Ok(c) => c, |
88 | Err(_) => return false, |
89 | }; |
90 | |
91 | match client.head(CHECK_URL).send().await { |
92 | Ok(response) => response.status().is_success() || response.status().as_u16() == 204, |
What happens:
- A HEAD request to
https://www.google.com/generate_204every 2 seconds - No user data, cookies, or identifiers are included — it is a bare HEAD request
- The response is only used to determine if the device is online (boolean)
Model Downloads
When you download a local STT model, Char fetches the model file from a hosting server.
These are the supported local models:
4 | pub static SUPPORTED_MODELS: [SupportedSttModel; 10] = [ |
5 | SupportedSttModel::Whisper(WhisperModel::QuantizedTiny), |
6 | SupportedSttModel::Whisper(WhisperModel::QuantizedTinyEn), |
7 | SupportedSttModel::Whisper(WhisperModel::QuantizedBase), |
8 | SupportedSttModel::Whisper(WhisperModel::QuantizedBaseEn), |
9 | SupportedSttModel::Whisper(WhisperModel::QuantizedSmall), |
10 | SupportedSttModel::Whisper(WhisperModel::QuantizedSmallEn), |
11 | SupportedSttModel::Whisper(WhisperModel::QuantizedLargeTurbo), |
12 | SupportedSttModel::Am(AmModel::ParakeetV2), |
13 | SupportedSttModel::Am(AmModel::ParakeetV3), |
What happens:
- Standard HTTP download requests to S3 (Whisper models) or Argmax hosting servers
- No user data is sent
- Downloaded models are verified with checksums before use
App Updates
Char checks for updates using the Tauri updater system.
What is sent:
- Your current app version and platform
Where it goes:
- CrabNebula — release hosting and update distribution
Authentication (When Signed In)
When you sign in for Pro or cloud features, Char authenticates via Supabase.
What is stored locally:
- Auth session tokens in the local Tauri store (
store.json) - Account info: user ID, email, full name, avatar URL
What is sent:
- Authentication requests to Supabase during sign-in
- Auth tokens to
pro.hyprnote.comwhen using Pro features
Summary
Char does not:
- Train AI models on your data
- Sell your data to third parties
- Collect your audio, transcripts, or notes for any purpose other than the features you explicitly use
- Send your meeting content in analytics — analytics only includes event names and app metadata
- Lock your data in a proprietary database — everything is plain Markdown and JSON files you can open, move, and use with other tools
To use Char completely offline: Use a local STT model for transcription and a local LLM (LM Studio or Ollama) for AI features. The only background network requests are the connectivity check and update checks.
To maximize privacy:
- Use a local STT model for transcription — see Local Models
- Use a local LLM (LM Studio or Ollama) for AI features — see Local LLM Setup
- Disable analytics in Settings
- Do not sign in or enable any cloud features
- Enable full-disk encryption on your OS (FileVault, LUKS, BitLocker)
Open Source
Char is open source. You can verify everything documented here by reading the code: