diff --git a/.env.example b/.env.example
index b19bf8e..8871f3c 100644
--- a/.env.example
+++ b/.env.example
@@ -71,3 +71,14 @@ JWT_TTL_DAYS=30
# Optional: Secrets encryption (for stored secrets)
# =============================================================================
# OPENAGENT_SECRET_PASSPHRASE=change-me
+
+# =============================================================================
+# Template Env Vars Encryption
+# =============================================================================
+# Static AES-256-GCM key for encrypting env_vars in workspace templates.
+# Format: 64 hex chars (32 bytes) or base64-encoded 32 bytes.
+# If not set on first template save, a key will be auto-generated and appended.
+# Encrypted values are stored as: BASE64(nonce||ciphertext)
+# Legacy plaintext values remain readable (backward compatible).
+#
+# PRIVATE_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
diff --git a/Cargo.toml b/Cargo.toml
index 485bfa6..8a9c4f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,6 +56,7 @@ aes-gcm = "0.10"
pbkdf2 = "0.12"
sha2 = "0.10"
rand = "0.8"
+hex = "0.4"
# Remote console / file manager
base64 = "0.22"
diff --git a/SHARED_TASK_NOTES.md b/SHARED_TASK_NOTES.md
new file mode 100644
index 0000000..7b3cfb1
--- /dev/null
+++ b/SHARED_TASK_NOTES.md
@@ -0,0 +1,85 @@
+# Encrypted Env Vars - Implementation Notes
+
+## Goal
+Encryption-at-rest for workspace template env vars using `PRIVATE_KEY` in `.env`.
+
+## Current Status
+- [x] Design complete
+- [x] `src/library/env_crypto.rs` implemented with full test coverage (13 tests)
+- [x] Integration into `get_workspace_template()` (decrypt on load)
+- [x] Integration into `save_workspace_template()` (encrypt on save)
+- [x] `.env.example` updated with PRIVATE_KEY documentation
+- [x] All 43 tests passing
+
+## What Was Implemented
+
+### Encryption Format
+```
+BASE64(nonce||ciphertext)
+```
+- Version in wrapper allows future format changes
+- 12-byte random nonce prepended to ciphertext
+- AES-256-GCM AEAD encryption
+
+### Key Functions (`src/library/env_crypto.rs`)
+- `is_encrypted(value)` - Check for wrapper format
+- `encrypt_value(key, plaintext)` - Returns wrapped encrypted string
+- `decrypt_value(key, value)` - Passthrough if plaintext, decrypt if wrapped
+- `encrypt_env_vars()` / `decrypt_env_vars()` - Batch operations for HashMap
+- `load_private_key_from_env()` - Load from PRIVATE_KEY (hex or base64)
+- `load_or_create_private_key(path)` - Auto-generate if missing (async)
+- `generate_private_key()` - Generate 32 random bytes
+
+### Integration Points
+- `LibraryStore::get_workspace_template()` - Decrypts after JSON parse
+- `LibraryStore::save_workspace_template()` - Encrypts before JSON serialize
+
+### Backward Compatibility
+- Plaintext values pass through unchanged on decrypt
+- Warning logged if encrypted values found but no key configured
+- Warning logged if saving plaintext when no key configured
+
+## Remaining Work
+
+### 1. Auto-generate key on startup (Priority)
+Currently `load_or_create_private_key()` exists but isn't called at startup.
+Need to integrate into application initialization to auto-generate the key.
+
+Look at `src/main.rs` or startup code to call:
+```rust
+let env_path = std::env::current_dir()?.join(".env");
+env_crypto::load_or_create_private_key(&env_path).await?;
+```
+
+### 2. Key rotation command
+Implement a CLI command or API endpoint to:
+1. Load old key from env
+2. Generate new key
+3. Re-encrypt all template env vars with new key
+4. Update .env with new key
+
+### 3. Integration tests
+Add tests that actually save/load templates through `LibraryStore` with encryption.
+Current tests only cover the crypto primitives.
+
+### 4. Dashboard UI verification
+Verify the dashboard displays plaintext env vars correctly (no UX regression).
+API endpoints should return decrypted values transparently.
+
+## Files Changed
+- `src/library/env_crypto.rs` (NEW) - Crypto utilities
+- `src/library/mod.rs` - Module declaration + template load/save integration
+- `Cargo.toml` - Added `hex = "0.4"` dependency
+- `.env.example` - Documented PRIVATE_KEY
+
+## Testing
+```bash
+cargo test --lib env_crypto # 13 crypto tests
+cargo test --lib # All 43 tests
+```
+
+## Notes
+- Key format: 64 hex chars OR base64-encoded 32 bytes
+- No double-encryption (already-encrypted values pass through)
+- Different encryptions produce different ciphertext (random nonce)
+- Existing `src/secrets/crypto.rs` uses passphrase-based PBKDF2 (different use case)
diff --git a/src/library/env_crypto.rs b/src/library/env_crypto.rs
new file mode 100644
index 0000000..9cd403d
--- /dev/null
+++ b/src/library/env_crypto.rs
@@ -0,0 +1,415 @@
+//! Encryption utilities for workspace template environment variables.
+//!
+//! Uses AES-256-GCM with a static key stored in PRIVATE_KEY environment variable.
+//! Encrypted values are wrapped in `BASE64` format
+//! for autodetection. Plaintext values (no wrapper) are treated as legacy.
+
+use aes_gcm::{
+ aead::{Aead, KeyInit},
+ Aes256Gcm, Nonce,
+};
+use anyhow::{anyhow, Context, Result};
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use rand::RngCore;
+use std::collections::HashMap;
+use std::path::Path;
+use tokio::fs;
+use tokio::io::AsyncWriteExt;
+
+/// Key length in bytes (256 bits for AES-256)
+const KEY_LENGTH: usize = 32;
+
+/// Nonce length in bytes (96 bits for AES-GCM)
+const NONCE_LENGTH: usize = 12;
+
+/// Environment variable name for the encryption key
+pub const PRIVATE_KEY_ENV: &str = "PRIVATE_KEY";
+
+/// Current encryption format version
+const ENCRYPTION_VERSION: &str = "1";
+
+/// Wrapper prefix for encrypted values
+const ENCRYPTED_PREFIX: &str = "";
+
+/// Check if a value is encrypted (has the wrapper format).
+pub fn is_encrypted(value: &str) -> bool {
+ let trimmed = value.trim();
+ trimmed.starts_with(ENCRYPTED_PREFIX) && trimmed.ends_with(ENCRYPTED_SUFFIX)
+}
+
+/// Parse an encrypted value, returning (version, base64_payload).
+fn parse_encrypted(value: &str) -> Option<(&str, &str)> {
+ let trimmed = value.trim();
+ if !trimmed.starts_with(ENCRYPTED_PREFIX) || !trimmed.ends_with(ENCRYPTED_SUFFIX) {
+ return None;
+ }
+
+ // Find the closing `">` of the version attribute
+ let after_prefix = &trimmed[ENCRYPTED_PREFIX.len()..];
+ let version_end = after_prefix.find("\">")?;
+ let version = &after_prefix[..version_end];
+
+ // Extract the base64 payload between `">` and ``
+ let payload_start = ENCRYPTED_PREFIX.len() + version_end + 2; // +2 for `">`
+ let payload_end = trimmed.len() - ENCRYPTED_SUFFIX.len();
+ let payload = &trimmed[payload_start..payload_end];
+
+ Some((version, payload))
+}
+
+/// Encrypt a plaintext value using AES-256-GCM.
+/// Returns the value wrapped in `BASE64(nonce||ciphertext)`.
+pub fn encrypt_value(key: &[u8; KEY_LENGTH], plaintext: &str) -> Result {
+ // Don't double-encrypt
+ if is_encrypted(plaintext) {
+ return Ok(plaintext.to_string());
+ }
+
+ // Generate random nonce
+ let mut nonce_bytes = [0u8; NONCE_LENGTH];
+ rand::thread_rng().fill_bytes(&mut nonce_bytes);
+
+ // Create cipher and encrypt
+ let cipher =
+ Aes256Gcm::new_from_slice(key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
+ let nonce = Nonce::from_slice(&nonce_bytes);
+
+ let ciphertext = cipher
+ .encrypt(nonce, plaintext.as_bytes())
+ .map_err(|e| anyhow!("Encryption failed: {}", e))?;
+
+ // Combine nonce + ciphertext and encode
+ let mut combined = Vec::with_capacity(NONCE_LENGTH + ciphertext.len());
+ combined.extend_from_slice(&nonce_bytes);
+ combined.extend_from_slice(&ciphertext);
+
+ let encoded = BASE64.encode(&combined);
+
+ Ok(format!(
+ "{}",
+ ENCRYPTION_VERSION, encoded
+ ))
+}
+
+/// Decrypt an encrypted value.
+/// If the value is plaintext (no wrapper), returns it unchanged.
+pub fn decrypt_value(key: &[u8; KEY_LENGTH], value: &str) -> Result {
+ // Passthrough plaintext values
+ let (version, payload) = match parse_encrypted(value) {
+ Some(parsed) => parsed,
+ None => return Ok(value.to_string()),
+ };
+
+ // Validate version
+ if version != ENCRYPTION_VERSION {
+ return Err(anyhow!(
+ "Unsupported encryption version: {}. Expected: {}",
+ version,
+ ENCRYPTION_VERSION
+ ));
+ }
+
+ // Decode base64
+ let combined = BASE64
+ .decode(payload)
+ .context("Failed to decode encrypted value")?;
+
+ if combined.len() < NONCE_LENGTH {
+ return Err(anyhow!("Encrypted value too short"));
+ }
+
+ // Split nonce and ciphertext
+ let (nonce_bytes, ciphertext) = combined.split_at(NONCE_LENGTH);
+
+ // Create cipher and decrypt
+ let cipher =
+ Aes256Gcm::new_from_slice(key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
+ let nonce = Nonce::from_slice(nonce_bytes);
+
+ let plaintext = cipher
+ .decrypt(nonce, ciphertext)
+ .map_err(|_| anyhow!("Decryption failed: invalid key or corrupted data"))?;
+
+ String::from_utf8(plaintext).context("Decrypted value is not valid UTF-8")
+}
+
+/// Encrypt all values in an env_vars HashMap.
+/// Values that are already encrypted are left unchanged.
+pub fn encrypt_env_vars(
+ key: &[u8; KEY_LENGTH],
+ env_vars: &HashMap,
+) -> Result> {
+ let mut encrypted = HashMap::with_capacity(env_vars.len());
+ for (k, v) in env_vars {
+ encrypted.insert(k.clone(), encrypt_value(key, v)?);
+ }
+ Ok(encrypted)
+}
+
+/// Decrypt all values in an env_vars HashMap.
+/// Plaintext values are passed through unchanged.
+pub fn decrypt_env_vars(
+ key: &[u8; KEY_LENGTH],
+ env_vars: &HashMap,
+) -> Result> {
+ let mut decrypted = HashMap::with_capacity(env_vars.len());
+ for (k, v) in env_vars {
+ decrypted.insert(k.clone(), decrypt_value(key, v)?);
+ }
+ Ok(decrypted)
+}
+
+/// Load the encryption key from environment.
+/// Returns None if PRIVATE_KEY is not set.
+pub fn load_private_key_from_env() -> Result