Advanced Lifecycle

Memory Compression

⏰ ~35 min to implement 📦 Requires: Dakera v0.11+

Compress high-volume memory stores by clustering related memories, summarizing each cluster with an LLM, and replacing the verbose originals with compact canonical representations. Maintain recall quality while cutting storage by 70–85%.

Start Free →
Prerequisites
  • Running Dakera server (Quickstart)
  • An agent ID with 500+ accumulated memories
  • LLM client (OpenAI, Anthropic) for cluster summarization
  • Familiarity with batch operations and scheduled jobs

The Problem: Memory Stores That Grow Faster Than They Age

An analytics agent processing daily reports stores 50–200 new memories per day. Over three months, that is 4,500–18,000 memories. Importance-based decay handles low-value memories, but many are legitimately important in the short term and accumulate faster than they fade. The result: a bloated memory store where recall latency climbs, context windows overflow, and storage costs grow linearly with agent runtime.

Deduplication removes near-exact copies. Decay removes low-importance entries. But neither handles the class of memories that are individually distinct and important, but collectively redundant at a higher level of abstraction. A week of daily "DAU increased 3.2%" reports is ten distinct facts — but they can be compressed into one: "DAU grew 3.1% average over the week of May 13." Memory compression operates at this higher level.

Compression vs. Deduplication vs. Decay

Decay removes low-importance memories over time. Deduplication merges near-identical memories. Compression clusters semantically related memories (even if distinct) and replaces them with a denser, higher-level summary. Use all three together for maximum memory hygiene: decay → dedup → compress, in that order.

Architecture: Cluster, Summarize, Replace

The compression pipeline runs in three stages. First, all agent memories above a minimum importance threshold are clustered using semantic similarity (HNSW/k-means on embeddings). Each cluster groups related memories. Second, each cluster is passed to an LLM for summarization — the output is a single, dense canonical memory that preserves the key facts from all cluster members. Third, the originals in each cluster are demoted (importance drops to 0.1) and the canonical summary is stored at high importance.

  • Cluster memories by semantic similarity (cluster_size: 5–20 memories per cluster)
  • Summarize each cluster with an LLM — preserve quantitative facts, dates, and conclusions
  • Store the canonical summary at importance 0.85–0.92, tagged ["compressed", "cluster-{id}"]
  • Demote all originals in the cluster to importance 0.08–0.12 to enable decay
  • Run compression monthly or when the store exceeds a size threshold

Diagram: Memory Volume Before and After Compression

18k 12k 8k 2k Week 1 Week 4 Week 8 Week 12 Week 16 no compression compress with compression compress compress -78% time (weeks) → memory count

Diagram: Compression Pipeline

RAW MEMORIES DAU +3.2% Mon DAU +2.9% Tue Revenue +1.1% Wed DAU +3.8% Thu Revenue +0.8% Fri ... 95 more 100 raw memories 1. cluster CLUSTERS Cluster A: DAU 38 memories, sim 0.89 Cluster B: Revenue 29 memories, sim 0.87 Cluster C: Churn 21 memories, sim 0.91 3 clusters from 100 2. summarize LLM per cluster 3. replace COMPRESSED STORE imp: 0.90 "DAU avg +3.3%/day May wk1-2" imp: 0.88 "Revenue avg +0.95%/day May wk1-2" imp: 0.87 "Churn rate steady 2.1% May wk1-2" 3 memories replace 100 (-97%)

Real-World Scenario: Analytics Agent Compressing Weeks of Data

Scenario: MetricsMind AI builds an analytics agent that monitors SaaS KPIs. The agent stores one memory per metric per day: DAU, revenue, churn, NPS, support tickets. With 8 metrics tracked daily, it accumulates 240 memories per month. After 3 months: 720 raw data-point memories. Recall for "revenue trend Q1" returns dozens of individual day-level facts, overwhelming the context window.

MetricsMind runs monthly compression. The pipeline clusters memories by metric type (cluster A: all DAU memories, cluster B: all revenue memories, etc.), then calls GPT-4o to summarize each cluster: "DAU grew from 12,400 to 15,200 over Q1 (+22.6%), with a 3-day dip in week 8 due to a login outage." The 90 raw day-level memories compress to 8 cluster summaries. The agent's memory store shrinks by 91%. Recall for any metric now returns exactly one clean, human-readable trend summary.

Step-by-Step Implementation

  1. Set up the compression trigger
    Define when compression runs: on a schedule (weekly/monthly), when memory count exceeds a threshold (e.g., 500 agent memories), or after batch ingestion. For analytics agents, monthly compression after the end-of-month data export is the natural trigger. Do not run compression more than weekly — it is computationally expensive.
  2. Retrieve all candidate memories
    Use search_memories or recall with a broad query to retrieve all memories above the compression threshold. Filter to the correct time range (e.g., memories older than 30 days, below importance 0.8). High-importance summaries from previous compression runs should be excluded — they are already compressed.
  3. Cluster by semantic similarity
    Group retrieved memories into clusters using semantic similarity. For small stores (<500), use a simple greedy clustering: for each memory, find the most similar unclustered memory (sim > 0.78) and assign them to the same cluster. For large stores, use k-means on the embeddings. Target cluster sizes of 5–30 memories.
  4. Summarize each cluster with an LLM
    For each cluster, pass all member contents to an LLM with a structured summarization prompt. The prompt should instruct the LLM to: preserve all quantitative values (numbers, percentages, dates), identify the time range covered, and produce a single dense paragraph. Validate that the output is non-empty and contains the key metrics before storing.
  5. Store compressed summaries at high importance
    Store each cluster summary at importance 0.85–0.92 with tags ["compressed", "cluster-{uuid}", "range-{start}-{end}"]. The range tags enable targeted retrieval (e.g., recall only Q1 summaries). Set no TTL — compressed summaries are the permanent record.
  6. Demote originals to trigger decay
    Use batch_forget for hard delete, or call update_importance for each original to set importance to 0.08. Soft demotion is preferred when audit requirements exist. Hard delete is appropriate for analytics data where raw day-level points are no longer needed after compression.

Before & After: Analytics Memory Store

Before compression — 7 raw memories (excerpt)
[
  { "id": "m-001", "content": "DAU: 12,421 on May 13",
    "importance": 0.55, "tags": ["dau", "daily"] },
  { "id": "m-002", "content": "DAU: 12,509 on May 14",
    "importance": 0.55, "tags": ["dau", "daily"] },
  { "id": "m-003", "content": "DAU: 12,388 on May 15 (login outage)",
    "importance": 0.65, "tags": ["dau", "daily", "incident"] },
  { "id": "m-004", "content": "Revenue: $48,220 on May 13",
    "importance": 0.60, "tags": ["revenue", "daily"] },
  { "id": "m-005", "content": "Revenue: $49,100 on May 14",
    "importance": 0.60, "tags": ["revenue", "daily"] },
  { "id": "m-006", "content": "Churn: 2.08% week of May 13",
    "importance": 0.58, "tags": ["churn", "weekly"] },
  // ... 93 more similar memories
]
// recall("DAU trend") returns 38 individual day records
// context window filled with raw data points
After compression — 3 summaries
[
  {
    "id": "m-comp-dau-001",
    "content": "DAU SUMMARY May 13-31: avg 12,890/day, peak 13,420 (May 29), low 12,388 (May 15, login outage). Growth +3.8% vs prior period.",
    "importance": 0.90,
    "tags": ["compressed", "cluster-dau", "range-may13-may31", "dau"]
  },
  {
    "id": "m-comp-rev-001",
    "content": "REVENUE SUMMARY May 13-31: avg $48,980/day, total $930,620. Steady +0.95%/day trend. No anomalies.",
    "importance": 0.88,
    "tags": ["compressed", "cluster-revenue", "range-may13-may31", "revenue"]
  },
  {
    "id": "m-comp-churn-001",
    "content": "CHURN SUMMARY May wk2-wk4: stable at 2.1% avg. Slight uptick 2.3% week of May 20 (investigated, no root cause). Within target.",
    "importance": 0.87,
    "tags": ["compressed", "cluster-churn", "range-may13-may31", "churn"]
  }
]
// recall("DAU trend") returns m-comp-dau-001 only
// Full month's insight in one clean memory

Implementation

# 1. Retrieve all raw analytics memories (older than 30 days)
curl "http://localhost:3300/v1/memory/recall?agent_id=analytics-bot&query=DAU+daily+metrics&min_importance=0.3&top_k=100"   -H "Authorization: Bearer dk-..."

# 2. After LLM summarization, store compressed cluster summary
curl -X POST http://localhost:3300/v1/memory/store   -H "Authorization: Bearer dk-..."   -H "Content-Type: application/json"   -d '{
    "agent_id": "analytics-bot",
    "content": "DAU SUMMARY May 13-31: avg 12,890/day, peak 13,420 (May 29), low 12,388 (May 15 login outage). Growth +3.8% vs prior.",
    "importance": 0.90,
    "memory_type": "semantic",
    "tags": ["compressed", "cluster-dau", "range-may13-may31", "dau"]
  }'

# 3. Hard-delete originals after compression (analytics — no audit needed)
curl -X POST http://localhost:3300/v1/memory/batch-forget   -H "Authorization: Bearer dk-..."   -H "Content-Type: application/json"   -d '{
    "agent_id": "analytics-bot",
    "memory_ids": ["m-001", "m-002", "m-003", "m-004", "m-005", "m-006"]
  }'

# 4. Verify: recall now returns only the compressed summary
curl "http://localhost:3300/v1/memory/recall?agent_id=analytics-bot&query=DAU+trend+May&min_importance=0.7&top_k=5"   -H "Authorization: Bearer dk-..."
from dakera import DakeraClient
import openai
import uuid
from datetime import datetime, timedelta
from itertools import combinations
import numpy as np

client = DakeraClient(base_url="http://localhost:3300", api_key="dk-...")
oai = openai.OpenAI()
AGENT = "analytics-bot"

def cosine_sim(a, b):
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

def cluster_memories(memories, threshold=0.78):
    """Greedy clustering by embedding similarity."""
    clusters = []
    assigned = set()
    for i, m in enumerate(memories):
        if i in assigned:
            continue
        cluster = [m]
        assigned.add(i)
        emb_i = m.embedding or []
        for j, n in enumerate(memories):
            if j in assigned or not n.embedding:
                continue
            if cosine_sim(emb_i, n.embedding) >= threshold:
                cluster.append(n)
                assigned.add(j)
        clusters.append(cluster)
    return clusters

def summarize_cluster(cluster):
    """LLM summarization of a memory cluster."""
    contents = "
".join([f"- {m.content}" for m in cluster])
    resp = oai.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "system",
            "content": "Summarize these analytics data points into a single dense paragraph. Preserve all quantitative values, dates, and trends. Highlight anomalies."
        }, {
            "role": "user",
            "content": f"Data points:
{contents}"
        }]
    )
    return resp.choices[0].message.content

def compress_agent_memories(date_cutoff_days: int = 30, min_cluster_size: int = 3):
    """Full compression pipeline for the analytics agent."""
    cutoff = datetime.now() - timedelta(days=date_cutoff_days)

    # Retrieve candidate memories (broad query to get many results)
    candidates = client.recall(
        agent_id=AGENT,
        query="daily metrics analytics report",
        min_importance=0.3,
        top_k=200
    )

    # Filter: exclude already-compressed and very recent memories
    raw = [m for m in candidates.memories
           if "compressed" not in (m.tags or [])
           and m.importance < 0.8]

    if len(raw) < min_cluster_size:
        print("Not enough memories to compress.")
        return

    # Cluster by semantic similarity
    clusters = cluster_memories(raw, threshold=0.78)
    print(f"Found {len(clusters)} clusters from {len(raw)} memories")

    compressed_count = 0
    for cluster in clusters:
        if len(cluster) < min_cluster_size:
            continue  # Skip tiny clusters — not worth compressing

        # Derive tags from cluster member tags
        all_tags = set()
        mem_ids = []
        for m in cluster:
            all_tags.update(m.tags or [])
            mem_ids.append(m.id)

        # Summarize cluster with LLM
        summary = summarize_cluster(cluster)
        if not summary or len(summary) < 50:
            continue  # Skip empty or trivial summaries

        cluster_id = str(uuid.uuid4())[:8]
        # Store compressed summary
        client.store_memory(
            agent_id=AGENT,
            content=summary,
            importance=0.90,
            memory_type="semantic",
            tags=["compressed", f"cluster-{cluster_id}"] + list(all_tags - {"compressed"})
        )

        # Hard-delete originals (analytics: no audit needed)
        client.batch_forget(request={
            "agent_id": AGENT,
            "memory_ids": mem_ids
        })
        compressed_count += len(cluster)
        print(f"Cluster {cluster_id}: {len(cluster)} memories -> 1 summary")

    print(f"Compression complete: {compressed_count} memories -> {len(clusters)} summaries")

# Run monthly compression
compress_agent_memories(date_cutoff_days=30, min_cluster_size=3)
import { DakeraClient } from '@dakera-ai/dakera';
import OpenAI from 'openai';
import { randomUUID } from 'crypto';

const client = new DakeraClient({ baseUrl: 'http://localhost:3300', apiKey: 'dk-...' });
const oai = new OpenAI();
const AGENT = 'analytics-bot';

function cosineSim(a: number[], b: number[]): number {
  const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
  const normA = Math.sqrt(a.reduce((s, x) => s + x * x, 0));
  const normB = Math.sqrt(b.reduce((s, x) => s + x * x, 0));
  return dot / (normA * normB);
}

function clusterMemories(memories: any[], threshold = 0.78): any[][] {
  const assigned = new Set<number>();
  const clusters: any[][] = [];
  for (let i = 0; i < memories.length; i++) {
    if (assigned.has(i)) continue;
    const cluster = [memories[i]];
    assigned.add(i);
    for (let j = i + 1; j < memories.length; j++) {
      if (assigned.has(j)) continue;
      const embI = memories[i].embedding ?? [];
      const embJ = memories[j].embedding ?? [];
      if (embI.length && embJ.length && cosineSim(embI, embJ) >= threshold) {
        cluster.push(memories[j]);
        assigned.add(j);
      }
    }
    clusters.push(cluster);
  }
  return clusters;
}

async function summarizeCluster(cluster: any[]): Promise<string> {
  const contents = cluster.map(m => `- ${m.content}`).join('
');
  const res = await oai.chat.completions.create({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: 'Summarize these analytics data points into a single dense paragraph. Preserve all quantitative values, dates, and anomalies.' },
      { role: 'user', content: `Data:
${contents}` }
    ]
  });
  return res.choices[0].message.content ?? '';
}

async function compressAgentMemories(dateCutoffDays = 30, minClusterSize = 3) {
  const candidates = await client.recall(AGENT, 'daily metrics analytics report', {
    min_importance: 0.3,
    top_k: 200
  });

  const raw = candidates.memories.filter(
    m => !(m.tags ?? []).includes('compressed') && m.importance < 0.8
  );

  if (raw.length < minClusterSize) { console.log('Not enough to compress.'); return; }

  const clusters = clusterMemories(raw);
  console.log(`Found ${clusters.length} clusters from ${raw.length} memories`);

  for (const cluster of clusters) {
    if (cluster.length < minClusterSize) continue;
    const summary = await summarizeCluster(cluster);
    if (!summary || summary.length < 50) continue;

    const allTags = [...new Set(cluster.flatMap(m => m.tags ?? []))].filter(t => t !== 'compressed');
    const clusterId = randomUUID().slice(0, 8);
    const memIds = cluster.map(m => m.id);

    await client.storeMemory(AGENT, {
      content: summary,
      importance: 0.90,
      memoryType: 'semantic',
      tags: ['compressed', `cluster-${clusterId}`, ...allTags]
    });
    await client.batchForget({ agent_id: AGENT, memory_ids: memIds });
    console.log(`Cluster ${clusterId}: ${cluster.length} memories -> 1 summary`);
  }
}

await compressAgentMemories(30, 3);
use dakera_rs::{Client, StoreMemoryRequest, RecallRequest};

let client = Client::new("http://localhost:3300", "dk-...");
let agent = "analytics-bot";

// 1. Retrieve candidate memories
let candidates = client.recall(agent, RecallRequest {
    query: "daily metrics analytics report".into(),
    min_importance: Some(0.3),
    top_k: Some(200),
    ..Default::default()
}).await?;

// 2. Filter: skip already-compressed and high-importance
let raw: Vec<_> = candidates.memories.iter()
    .filter(|m| !m.tags.contains(&"compressed".to_string()) && m.importance < 0.8)
    .collect();

// 3. Cluster by embedding similarity (your clustering logic)
let clusters = cluster_by_similarity(&raw, 0.78);

// 4. For each cluster: summarize with LLM and store
for cluster in clusters {
    if cluster.len() < 3 { continue; }
    let summary = summarize_with_llm(&cluster).await?; // your LLM call
    let cluster_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
    let mem_ids: Vec<String> = cluster.iter().map(|m| m.id.clone()).collect();

    // Store compressed summary
    client.store_memory(agent, StoreMemoryRequest {
        content: summary,
        importance: Some(0.90),
        memory_type: "semantic".into(),
        tags: vec!["compressed".into(), format!("cluster-{}", cluster_id)],
        ..Default::default()
    }).await?;

    // Hard-delete originals via REST: POST /v1/memory/batch-forget
    // { "agent_id": "analytics-bot", "memory_ids": [mem_ids] }
    println!("Cluster {}: {} -> 1 summary", cluster_id, cluster.len());
}
package main

import (
    "context"
    "fmt"
    dakera "github.com/dakera-ai/dakera-go"
)

func compressAgentMemories(ctx context.Context, client *dakera.Client, agent string) {
    // 1. Retrieve candidate memories
    candidates, _ := client.Recall(ctx, agent, dakera.RecallRequest{
        Query: "daily metrics analytics report", MinImportance: 0.3, TopK: 200,
    })

    // 2. Filter: skip compressed and high-importance
    var raw []dakera.Memory
    for _, m := range candidates.Memories {
        if !contains(m.Tags, "compressed") && m.Importance < 0.8 {
            raw = append(raw, m)
        }
    }
    if len(raw) < 3 { fmt.Println("Not enough to compress."); return }

    // 3. Cluster by similarity (your clustering logic)
    clusters := clusterBySimilarity(raw, 0.78)
    fmt.Printf("Found %d clusters from %d memories
", len(clusters), len(raw))

    for _, cluster := range clusters {
        if len(cluster) < 3 { continue }
        summary := summarizeWithLLM(cluster) // your LLM call
        clusterID := randomID(8)
        memIDs := make([]string, len(cluster))
        for i, m := range cluster { memIDs[i] = m.ID }

        // Store compressed summary
        client.StoreMemory(ctx, agent, dakera.StoreMemoryRequest{
            Content:    summary,
            Importance: 0.90,
            MemoryType: "semantic",
            Tags:       []string{"compressed", "cluster-" + clusterID},
        })
        // Batch-delete originals
        client.BatchForget(ctx, dakera.BatchForgetRequest{AgentID: agent, MemoryIDs: memIDs})
        fmt.Printf("Cluster %s: %d memories -> 1 summary
", clusterID, len(cluster))
    }
}

91% storage reduction on analytics memory stores

Dakera's batch_forget and store primitives make compression a 50-line pipeline.

Deploy Free →

SDK Reference

MethodSDKPurpose
recall(agent_id, query, min_importance, top_k)PythonRetrieve candidate memories for clustering
recall(agentId, query, {min_importance, top_k})TypeScriptRetrieve candidate memories for clustering
search_memories(agent_id, query)PythonBroad search to retrieve all memories in a topic area
searchMemories(agentId, query, {top_k})TypeScriptBroad search to retrieve all memories in a topic area
store_memory(agent_id, content, importance, tags)PythonStore the compressed cluster summary
storeMemory(agentId, {content, importance, tags})TypeScriptStore the compressed cluster summary
batch_forget(request)PythonHard-delete original memories after compression
batchForget(request)TypeScriptHard-delete original memories after compression
update_importance(agent_id, memory_id, importance)PythonSoft-demote originals (audit-friendly alternative to delete)
updateImportance(agentId, {memory_id, importance})TypeScriptSoft-demote originals (audit-friendly alternative to delete)

Performance Considerations

91%
Storage reduction on analytics agents (100 memories to ~9 summaries)
45s
Full compression pipeline for 500 memories (LLM + Dakera writes)
3x
Recall speed improvement post-compression on analytics queries
  • LLM calls are the dominant cost. With 10 clusters and GPT-4o at $0.01/1k output tokens, a full compression run costs $0.05–$0.20. Use GPT-4o-mini for smaller datasets (fewer quantitative facts) to reduce cost to $0.005–$0.02 per run.
  • Clustering is O(N²) for greedy clustering. For 500 memories, greedy clustering takes ~2 seconds. For 5,000 memories, use k-means on embeddings (scikit-learn or faiss) to reduce clustering to O(N log N). Switch to k-means when your memory count regularly exceeds 1,000.
  • batch_forget is 8x faster than individual forgets. Always use batch delete when removing cluster members. With 38 members per cluster and 10 clusters, that is 380 individual delete calls vs. 10 batch calls — a ~40-second difference in wall time.
  • Recall diversity improves dramatically. Before compression, a query for "May analytics" returns 38 DAU daily records — effectively one answer repeated 38 times. After compression, the same query returns 3 summaries covering DAU, revenue, and churn — genuine diversity. Measure recall diversity before and after to validate compression quality.

Edge Cases

Edge Case 1: LLM Hallucinating Metrics in Summaries

LLMs can hallucinate numerical values in summarization, especially when the input data has irregular patterns. Always validate compressed summaries by checking that all numerical values in the output appear in at least one input memory. Use a regex extraction pass to compare input values vs. output values, and re-run the LLM call with a stricter prompt if values diverge by more than 5%.

Edge Case 2: Compressing Memories That Cover Different Time Ranges

Clustering by semantic similarity may group "DAU on May 15" with "DAU on July 22" — same metric, very different time periods. Before clustering, filter memories by a time window first: only cluster memories from the same 4-week period together. Never compress across more than 6 weeks into a single summary or you lose temporal precision.

Edge Case 3: Singleton Clusters After Aggressive Thresholds

With a high similarity threshold (0.90+), many memories end up as singleton clusters — no similar memories found. Compressing singletons produces a "summary" identical to the original. Add a minimum cluster size check (at least 3 members) before calling the LLM. Singletons can be left as-is or demoted with importance decay instead.

Edge Case 4: Preserving Anomalies Through Compression

High-signal anomalies (a login outage on May 15 causing DAU to drop) can be diluted in a summary that averages 38 normal days. Before compressing a cluster, identify anomalous members (importance > 0.75, or tagged "incident") and store them separately as high-importance standalone memories before the cluster is compressed. The summary captures the trend; the anomaly memory captures the exception.

Edge Case 5: Compressing Already-Compressed Summaries

After 3 months, you may have 12 weekly summaries for the same metric. These can themselves be compressed into a quarterly summary. Design a multi-tier compression hierarchy: daily → weekly summary → monthly summary → quarterly summary. Tag each tier: "tier-1", "tier-2", "tier-3". Always exclude higher tiers from lower-tier compression runs to prevent recursive summarization.

Advanced Configuration: Clustering Strategies & LLM Prompt Templates

Clustering Strategy by Store Size

Store SizeClustering MethodThresholdEst. Runtime
<200 memoriesGreedy pairwise0.78<1s
200–1,000 memoriesGreedy pairwise0.801–5s
1,000–5,000 memoriesk-means on embeddings (k=50)0.785–15s
>5,000 memoriesHNSW + label propagation0.8215–60s

Analytics Summarization Prompt

SYSTEM: You are an analytics data summarizer.
Summarize the following data points into a single paragraph.
Rules:
1. Preserve ALL numerical values exactly as given (no rounding beyond source precision)
2. State the date range covered
3. Identify the central metric (DAU, revenue, churn, etc.)
4. Highlight any anomalies or outliers (not just the average trend)
5. End with: overall direction (growing/stable/declining)
6. Keep the summary under 120 words
Do not invent values not present in the source data.

USER: Summarize:
{CLUSTER_CONTENTS}

Compression Schedule by Domain

Agent TypeCompression FrequencyMin Age Before Compress
Analytics / BI agentWeekly (after report cycle)7 days
Customer support agentMonthly30 days
Personal assistantMonthly60 days
Legal / compliance agentQuarterly90 days

91% Fewer Memories. 100% of the Insight.

Dakera's compression pattern turns weeks of raw data into precise, queryable summaries — automatically. Ship your first compression pipeline in 35 minutes.

Get Started Free →