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 anOFFLINEstate update so other members stop expecting acknowledgements, but you remain on the roster. The session state is preserved in the persistence store. Callrejointo 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 withrejoin.
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