Skip to content

Tutorial: Session Persistence and Restore

This tutorial shows how to persist group session state so that an application can restart and resume its sessions without repeating the invite and MLS key-exchange handshake. It also covers the close and rejoin lifecycle for participants that need to go offline temporarily and come back.

Prerequisites

  • Completed Creating a Session — you need a running group session with at least one invited participant
  • A writable directory on disk for the session store (e.g. ./slim-state/)

For conceptual background see Session State Persistence and Participant Liveness and Disconnection Detection.

Step 1: Create an App with Persistence Enabled

Instead of create_app_with_secret, use create_app_with_persistence. Pass a PersistenceConfig specifying the storage directory and a passphrase to encrypt the state at rest.

Passphrase

Always set a passphrase in production. Without one, the store is authenticated but not confidential — anyone who can read the database file and knows the app name can decrypt it.

use slim_service::Service;
use slim_service::config::ClientConfig;
use slim_auth::shared_secret::SharedSecret;
use slim_datapath::api::ProtoName;
use slim_persistence::PersistenceConfig;
use slim_session::Direction;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let service = Service::builder().build("slim/0")?;
    service.run().await?;
    let conn_id = service.connect(ClientConfig::with_endpoint("http://127.0.0.1:46357")).await?;

    let name = ProtoName::from_strings(["myorg", "default", "my-service"]);
    let provider = SharedSecret::new("myorg/default/my-service", "change-me-before-going-to-production")?;
    let verifier = SharedSecret::new("myorg/default/my-service", "change-me-before-going-to-production")?;

    let (app, _rx) = service.create_app_with_direction_and_persistence(
        &name,
        provider,
        verifier,
        Direction::Bidirectional,
        Some(PersistenceConfig::new("./slim-state")),
    )?;
    app.subscribe(&name, Some(conn_id)).await?;
    println!("App ready with persistence: myorg/default/my-service");

    Ok(())
}
import asyncio
import slim_bindings

async def main():
    slim_bindings.uniffi_set_event_loop(asyncio.get_running_loop())
    slim_bindings.initialize_with_defaults()

    service = slim_bindings.get_global_service()
    conn_id = await service.connect_async(
        slim_bindings.new_insecure_client_config("http://127.0.0.1:46357")
    )

    local_name = slim_bindings.Name("myorg", "default", "my-service")

    persistence = slim_bindings.PersistenceConfig(
        path="./slim-state",
        passphrase="change-me-in-production",
    )

    provider = slim_bindings.IdentityProviderConfig.SHARED_SECRET(
        id=str(local_name), data="change-me-before-going-to-production"
    )
    verifier = slim_bindings.IdentityVerifierConfig.SHARED_SECRET(
        id=str(local_name), data="change-me-before-going-to-production"
    )

    app = await service.create_app_with_persistence_async(
        local_name,
        provider,
        verifier,
        slim_bindings.Direction.BIDIRECTIONAL,
        persistence,
    )
    await app.subscribe_async(local_name, conn_id)
    print(f"App ready with persistence: {local_name}")
    return app, conn_id
import (
    "fmt"
    "log"

    slim "github.com/agntcy/slim-bindings-go/v2"
)

func main() {
    slim.InitializeWithDefaults()

    config := slim.NewInsecureClientConfig("http://127.0.0.1:46357")
    connID, err := slim.GetGlobalService().ConnectAsync(config)
    if err != nil {
        log.Fatal(err)
    }

    appName, _ := slim.NameFromString("myorg/default/my-service")
    passphrase := "change-me-in-production"

    persistence := slim.PersistenceConfig{
        Path:       "./slim-state",
        Passphrase: &passphrase,
    }
    provider := slim.IdentityProviderConfigSharedSecret{
        Id:   appName.String(),
        Data: "change-me-before-going-to-production",
    }
    verifier := slim.IdentityVerifierConfigSharedSecret{
        Id:   appName.String(),
        Data: "change-me-before-going-to-production",
    }

    app, err := slim.GetGlobalService().CreateAppWithPersistenceAsync(
        appName,
        provider,
        verifier,
        slim.DirectionBidirectional,
        persistence,
    )
    if err != nil {
        log.Fatal(err)
    }
    defer app.Destroy()

    if err := app.SubscribeAsync(app.Name(), &connID); err != nil {
        log.Fatal(err)
    }
    fmt.Println("App ready with persistence:", appName)
}
import io.agntcy.slim.bindings.*;

SlimBindings.initializeWithDefaults();
Service service = SlimBindings.getGlobalService();

ClientConfig config = SlimBindings.newInsecureClientConfig("http://127.0.0.1:46357");
Long connId = service.connect(config);

Name localName = Name.fromString("myorg/default/my-service");

PersistenceConfig persistence = new PersistenceConfig(
    "./slim-state",
    "change-me-in-production"
);
IdentityProviderConfig provider = new IdentityProviderConfig.SharedSecret(
    localName.toString(), "change-me-before-going-to-production"
);
IdentityVerifierConfig verifier = new IdentityVerifierConfig.SharedSecret(
    localName.toString(), "change-me-before-going-to-production"
);

App app = service.createAppWithPersistence(
    localName, provider, verifier,
    Direction.BIDIRECTIONAL, persistence
);
app.subscribe(app.name(), connId);

System.out.println("App ready with persistence: " + localName);
import io.agntcy.slim.bindings.*
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    initializeWithDefaults()
    val service = getGlobalService()

    val connId: ULong = service.connectAsync(newInsecureClientConfig("http://127.0.0.1:46357"))

    val localName = Name.fromString("myorg/default/my-service")

    val persistence = PersistenceConfig(
        path = "./slim-state",
        passphrase = "change-me-in-production"
    )
    val provider = IdentityProviderConfig.SharedSecret(
        id = localName.toString(), data = "change-me-before-going-to-production"
    )
    val verifier = IdentityVerifierConfig.SharedSecret(
        id = localName.toString(), data = "change-me-before-going-to-production"
    )

    val app = service.createAppWithPersistenceAsync(
        localName, provider, verifier,
        Direction.BIDIRECTIONAL, persistence
    )
    app.subscribeAsync(localName, connId)

    println("App ready with persistence: $localName")
}
import slimBindings from '@agntcy/slim-bindings';

slimBindings.initializeWithDefaults();
const service = slimBindings.getGlobalService();

const connId = await service.connectAsync(
    slimBindings.newInsecureClientConfig("http://127.0.0.1:46357")
);

const localName = new slimBindings.Name("myorg", "default", "my-service");

const persistence = {
    path: "./slim-state",
    passphrase: "change-me-in-production",
};
const provider = new slimBindings.IdentityProviderConfig.SharedSecret({
    id: localName.toString(), data: "change-me-before-going-to-production"
});
const verifier = new slimBindings.IdentityVerifierConfig.SharedSecret({
    id: localName.toString(), data: "change-me-before-going-to-production"
});

const app = await service.createAppWithPersistenceAsync(
    localName, provider, verifier,
    slimBindings.Direction.Bidirectional, persistence
);
await app.subscribeAsync(localName, connId);

console.log(`App ready with persistence: ${localName}`);
using Agntcy.Slim;

Slim.Initialize();

var connId = Slim.Connect("http://127.0.0.1:46357");

using var localName = SlimName.Parse("myorg/default/my-service");
using var service = Slim.GetGlobalService();

var persistence = new SlimPersistenceConfig(
    path: "./slim-state",
    passphrase: "change-me-in-production"
);
var provider = SlimIdentityProviderConfig.SharedSecret(localName.ToString(), "change-me-before-going-to-production");
var verifier = SlimIdentityVerifierConfig.SharedSecret(localName.ToString(), "change-me-before-going-to-production");

var app = await service.CreateAppWithPersistenceAsync(
    localName, provider, verifier,
    SlimDirection.Bidirectional, persistence
);
app.Subscribe(app.Name, connId);

Console.WriteLine($"App ready with persistence: {localName}");

Step 2: Use the Session Normally

Create a group session and exchange messages exactly as shown in Creating a Session. The session layer silently checkpoints MLS state and membership to the store as the session progresses — no additional calls required.

Step 3: Restore Sessions After a Restart

On the next startup, create a new app using the same name, secret, store path, and passphrase, then call restore_sessions. The session layer reads the persisted state, re-establishes routing, and rejoins the MLS group without repeating the full handshake.

// After restart — same name, secret, and store path as before
let (app, _rx) = service.create_app_with_direction_and_persistence(
    &name,
    provider,
    verifier,
    Direction::Bidirectional,
    Some(PersistenceConfig::new("./slim-state")),
)?;
app.subscribe(&name, Some(conn_id)).await?;

// Restore all previously active sessions
let sessions = app.restore_sessions(conn_id).await?;
println!("Restored {} session(s)", sessions.len());

// Each restored session is immediately usable
for session in &sessions {
    session.publish(&channel_name, b"back online".to_vec(), None, None).await?;
}
# After restart — same name, secret, path, and passphrase as before
app = await service.create_app_with_persistence_async(
    local_name, provider, verifier,
    slim_bindings.Direction.BIDIRECTIONAL, persistence,
)
await app.subscribe_async(local_name, conn_id)

# Restore all previously active sessions
sessions = await app.restore_sessions_async(conn_id)
print(f"Restored {len(sessions)} session(s)")

# Each restored session is immediately usable
for session in sessions:
    await session.publish_and_wait_async(b"back online", None, None)
// After restart — same name, secret, path, and passphrase as before
app, err = slim.GetGlobalService().CreateAppWithPersistenceAsync(
    appName, provider, verifier,
    slim.DirectionBidirectional, persistence,
)
if err != nil {
    log.Fatal(err)
}
defer app.Destroy()

if err := app.SubscribeAsync(app.Name(), &connID); err != nil {
    log.Fatal(err)
}

// Restore all previously active sessions
sessions, err := app.RestoreSessionsAsync(connID)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Restored %d session(s)\n", len(sessions))

// Each restored session is immediately usable
for _, session := range sessions {
    if err := session.PublishAndWaitAsync([]byte("back online"), nil, nil); err != nil {
        log.Println("publish error:", err)
    }
}
// After restart — same name, secret, path, and passphrase as before
App app = service.createAppWithPersistence(
    localName, provider, verifier,
    Direction.BIDIRECTIONAL, persistence
);
app.subscribe(app.name(), connId);

// Restore all previously active sessions
List<Session> sessions = app.restoreSessions(connId);
System.out.println("Restored " + sessions.size() + " session(s)");

// Each restored session is immediately usable
for (Session session : sessions) {
    session.publishAndWait("back online".getBytes(), null, null);
}
import kotlinx.coroutines.runBlocking

// After restart — same name, secret, path, and passphrase as before
runBlocking {
    val app = service.createAppWithPersistenceAsync(
        localName, provider, verifier,
        Direction.BIDIRECTIONAL, persistence
    )
    app.subscribeAsync(localName, connId)

    // Restore all previously active sessions
    val sessions = app.restoreSessionsAsync(connId)
    println("Restored ${sessions.size} session(s)")

    // Each restored session is immediately usable
    for (session in sessions) {
        session.publishAndWaitAsync("back online".toByteArray(), null, null)
    }
}
// After restart — same name, secret, path, and passphrase as before
const app = await service.createAppWithPersistenceAsync(
    localName, provider, verifier, slimBindings.Direction.Bidirectional, persistence
);
await app.subscribeAsync(localName, connId);

// Restore all previously active sessions
const sessions = await app.restoreSessionsAsync(connId);
console.log(`Restored ${sessions.length} session(s)`);

// Each restored session is immediately usable
for (const session of sessions) {
    await session.publishAndWaitAsync(Buffer.from("back online"), undefined, undefined);
}
// After restart — same name, secret, path, and passphrase as before
var app = await service.CreateAppWithPersistenceAsync(
    localName, provider, verifier,
    SlimDirection.Bidirectional, persistence
);
app.Subscribe(app.Name, connId);

// Restore all previously active sessions
var sessions = await app.RestoreSessionsAsync(connId);
Console.WriteLine($"Restored {sessions.Count} session(s)");

// Each restored session is immediately usable
foreach (var session in sessions)
{
    await session.PublishAsync("back online");
}

Close and Rejoin

close_with_mode takes a CloseMode that controls whether the close is temporary or permanent:

  • CloseMode::Soft — Go offline temporarily. Broadcasts an OFFLINE state update so other members stop expecting acknowledgements, but you remain on the roster. The session state is preserved in the persistence store. Call rejoin to come back; the session is restored without repeating the full handshake. Use this when working with persistence.
  • CloseMode::Hard — Terminate the session permanently. The session is removed from the store and cannot be restored with rejoin.

Group sessions only

close_with_mode and rejoin are only valid for group sessions. Calling either on a point-to-point session returns an error.

Close

Use CloseMode::Soft to pause participation while keeping the session alive in the store:

use slim_session::CloseMode;

// Soft close: go offline, stay on the roster, keep session in the store
session.close_with_mode(CloseMode::Soft).await?.await?;
println!("Offline — other members will stop expecting acks from us");
# Soft close: go offline, stay on the roster, keep session in the store
await session.close_with_mode_and_wait_async(slim_bindings.CloseMode.SOFT)
print("Offline — other members will stop expecting acks from us")
// Soft close: go offline, stay on the roster, keep session in the store
if err := session.CloseWithModeAndWaitAsync(slim.CloseModeSoft); err != nil {
    log.Fatal(err)
}
fmt.Println("Offline — other members will stop expecting acks from us")
// Soft close: go offline, stay on the roster, keep session in the store
session.closeWithModeAndWait(CloseMode.SOFT);
System.out.println("Offline — other members will stop expecting acks from us");
import kotlinx.coroutines.runBlocking

runBlocking {
    // Soft close: go offline, stay on the roster, keep session in the store
    session.closeWithModeAndWaitAsync(CloseMode.SOFT)
    println("Offline — other members will stop expecting acks from us")
}
// Soft close: go offline, stay on the roster, keep session in the store
await session.closeWithModeAndWaitAsync(slimBindings.CloseMode.Soft);
console.log("Offline — other members will stop expecting acks from us");
// Soft close: go offline, stay on the roster, keep session in the store
await session.CloseWithModeAndWaitAsync(SlimCloseMode.Soft);
Console.WriteLine("Offline — other members will stop expecting acks from us");

Rejoin

// Broadcast ONLINE and wait for acknowledgements
session.rejoin().await?.await?;
println!("Back online — MLS re-key complete");
# Broadcast ONLINE and wait for acknowledgements
await session.rejoin_and_wait_async()
print("Back online — MLS re-key complete")
// Broadcast ONLINE and wait for acknowledgements
if err := session.RejoinAndWaitAsync(); err != nil {
    log.Fatal(err)
}
fmt.Println("Back online — MLS re-key complete")
// Broadcast ONLINE and wait for acknowledgements
session.rejoinAndWait();
System.out.println("Back online — MLS re-key complete");
import kotlinx.coroutines.runBlocking

runBlocking {
    // Broadcast ONLINE and wait for acknowledgements
    session.rejoinAndWaitAsync()
    println("Back online — MLS re-key complete")
}
// Broadcast ONLINE and wait for acknowledgements
await session.rejoinAndWaitAsync();
console.log("Back online — MLS re-key complete");
// Broadcast ONLINE and wait for acknowledgements
await session.RejoinAndWaitAsync();
Console.WriteLine("Back online — MLS re-key complete");

Next Steps

  • Groups — Participant liveness, close/rejoin, and persistence concepts in detail
  • Creating a Session — Group session creation and the invite lifecycle
  • Receiving a Session — Listening for incoming sessions on a restored app