
S3 User Files
- 1 installs
- 177 repo stars
- Updated June 23, 2026
- aws-samples/sample-host-openclaw-on-amazon-bedrock-agentcore
s3-user-files is a skill that gives a hosted agent per-user persistent file storage on AWS S3 with read, write, list, delete and presign tools.
About
Adds per-user persistent file storage backed by AWS S3 to a hosted agent. It exposes read, write, list, delete, and presign tools that operate on an isolated per-user S3 namespace so there is no cross-user data leakage. An agent uses it to save preferences, remember notes, and generate temporary download URLs across sessions, always keyed to the user_id from the system prompt.
- Per-user S3 storage with read, write, list, delete and presign tools
- Isolated namespace per user via S3 key prefix, no cross-user access
- Path traversal sanitized and content encrypted at rest via S3 server-side encryption
S3 User Files by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,098 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
s3-user-files capabilities & compatibility
Runs against an AWS S3 bucket inside the AgentCore host; incurs standard S3 storage and request charges.
- Capabilities
- agentcore browser
- Works with
- aws
- Use cases
- memory · orchestration
- Runs
- Hosted SaaS
- Pricing
- Bring your own API key
What s3-user-files says it does
Per-user persistent file storage backed by AWS S3. Each user's files are stored in an isolated namespace — no cross-user data leakage.
Files are isolated per user via S3 key prefix: `{user_id}/{filename}`
npx skills add https://github.com/aws-samples/sample-host-openclaw-on-amazon-bedrock-agentcore --skill s3-user-filesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 177 |
| Last updated | June 23, 2026 |
| Repository | aws-samples/sample-host-openclaw-on-amazon-bedrock-agentcore ↗ |
What it does
Give a hosted agent per-user persistent file storage on S3 to save, read, list and delete user data across sessions.
Who is it for?
Agents that need to persist and recall user-specific data across sessions with isolation
Skip if: Shared or cross-user data, since each user's files are isolated by namespace
When should I use this skill?
The user asks to save, remember, or persist information across sessions
What you get
User data is saved to and read from an isolated per-user S3 namespace with presigned download URLs.
- Persisted user files
- File listings
- Presigned download URLs
By the numbers
- Presigned URL default 3600s, max 604800s
Files
S3 User Files
Per-user persistent file storage backed by AWS S3. Each user's files are stored in an isolated namespace — no cross-user data leakage.
Important
Always use the user_id from the system prompt when calling these tools. Never hardcode or guess a user_id. The system provides it automatically.
Usage
read_user_file
Read a file from the user's persistent storage.
node {baseDir}/read.js <user_id> <filename>user_id(required): The user's unique identifier (e.g.,telegram_12345)filename(required): The file name to read (e.g.,IDENTITY.md)
write_user_file
Write content to a file in the user's persistent storage.
node {baseDir}/write.js <user_id> <filename> <content>user_id(required): The user's unique identifierfilename(required): The file name to writecontent(required): The text content to write
list_user_files
List all files in the user's persistent storage.
node {baseDir}/list.js <user_id>user_id(required): The user's unique identifier
delete_user_file
Delete a file from the user's persistent storage.
node {baseDir}/delete.js <user_id> <filename>user_id(required): The user's unique identifierfilename(required): The file name to delete
presign_user_file
Generate a temporary download URL (presigned URL) for a file. The URL is valid for 1 hour by default.
node {baseDir}/presign.js <user_id> <filename> [expires_in_seconds]user_id(required): The user's unique identifierfilename(required): The file name to generate a URL forexpires_in_seconds(optional): URL validity in seconds (default: 3600, max: 604800)
Important: Always use this tool to generate presigned URLs. Do NOT use aws s3 presign CLI — it may produce SigV2 signatures that fail on KMS-encrypted buckets.
From Agent Chat
- "Save my preferences" -> write_user_file with the user's preferences
- "What do you remember about me?" -> read_user_file to check stored notes
- "What's your name?" -> read_user_file IDENTITY.md for this user
- "Forget everything about me" -> delete_user_file on each stored file
- "What files do you have for me?" -> list_user_files
- "Give me a download link for that file" -> presign_user_file
- "Generate a presigned URL for my screenshot" -> presign_user_file
Security Notes
- Files are isolated per user via S3 key prefix:
{user_id}/{filename} - user_id uses underscores (e.g.,
telegram_12345) — colons are replaced - Content encrypted at rest via S3 server-side encryption
- Bucket enforces SSL-only access
- Path traversal attempts are sanitized (removes
.., restricts characters) - Never use
default_useras user_id — scripts reject it with an error
/**
* Shared utilities for s3-user-files skill.
*/
const BUCKET = process.env.S3_USER_FILES_BUCKET;
const REGION = process.env.AWS_REGION;
if (!REGION) {
console.error("Error: AWS_REGION environment variable is not set.");
process.exit(1);
}
/**
* Sanitize a string for safe use as an S3 key component.
* Removes path traversal, restricts to safe characters, limits length.
*/
function sanitize(str) {
// Iteratively remove ".." until stable to prevent "...."->"..".
let result = str;
while (result.includes("..")) {
result = result.replace(/\.\./g, "");
}
result = result.replace(/[^a-zA-Z0-9_\-.]/g, "_").slice(0, 256);
// Reject leading/trailing dots (hidden files, path traversal)
if (result.startsWith(".") || result.endsWith(".")) {
throw new Error(
`Invalid filename: "${result}" — leading/trailing dots not allowed`,
);
}
return result;
}
/**
* Build the S3 key from userId and optional filename.
* Returns "sanitized_userId/" or "sanitized_userId/sanitized_filename".
*/
function buildKey(userId, filename) {
const prefix = sanitize(userId);
if (!filename) return `${prefix}/`;
return `${prefix}/${sanitize(filename)}`;
}
/**
* Validate that userId is present, not the default-user fallback,
* and matches the expected channel_identifier namespace pattern.
* Exits the process with an error message if validation fails.
*/
function validateUserId(userId) {
if (!userId) {
console.error("Error: user_id argument is required.");
process.exit(1);
}
if (userId === "default-user" || userId === "default_user") {
console.error(
"Error: Cannot operate on files for default-user. User identity was not resolved.",
);
process.exit(1);
}
// Namespace must match channel_identifier pattern (e.g., telegram_123456789, slack_U0AGD41CBGS).
// This prevents prompt injection attacks where a user tricks the AI into using
// an arbitrary namespace to access another user's files.
const VALID_NAMESPACE =
/^(telegram|slack|discord|whatsapp)_[a-zA-Z0-9_-]{1,64}$/;
if (!VALID_NAMESPACE.test(userId)) {
console.error(
`Error: Invalid user_id "${userId}". Must match channel_identifier format (e.g., telegram_123456, slack_username).`,
);
process.exit(1);
}
}
/**
* Validate that the S3_USER_FILES_BUCKET env var is set.
* Exits the process with an error message if missing.
*/
function validateBucket() {
if (!BUCKET) {
console.error("Error: S3_USER_FILES_BUCKET environment variable not set.");
process.exit(1);
}
}
/**
* Convert an S3 response body stream to a UTF-8 string.
*/
async function streamToString(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8");
}
module.exports = {
BUCKET,
REGION,
sanitize,
buildKey,
validateUserId,
validateBucket,
streamToString,
};
/**
* Tests for s3-user-files common utilities.
* Run: node --test common.test.js
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { sanitize, buildKey, validateUserId } = require("./common");
describe("sanitize", () => {
it("passes through simple alphanumeric strings", () => {
assert.equal(sanitize("telegram_12345"), "telegram_12345");
});
it("replaces colons with underscores", () => {
assert.equal(sanitize("telegram:12345"), "telegram_12345");
});
it("removes path traversal sequences", () => {
// ".." removed, remaining "/" become "_"
assert.equal(sanitize("../../../etc/passwd"), "___etc_passwd");
});
it("replaces slashes with underscores", () => {
assert.equal(sanitize("foo/bar/baz"), "foo_bar_baz");
});
it("allows hyphens and dots", () => {
assert.equal(sanitize("my-file.md"), "my-file.md");
});
it("truncates to 256 characters", () => {
const long = "a".repeat(300);
assert.equal(sanitize(long).length, 256);
});
it("handles empty string", () => {
assert.equal(sanitize(""), "");
});
it("replaces spaces with underscores", () => {
assert.equal(sanitize("John Doe"), "John_Doe");
});
it("handles Slack user IDs", () => {
assert.equal(sanitize("slack:U0123456789"), "slack_U0123456789");
});
it("iteratively removes nested dot-dot sequences", () => {
// "......" (6 dots) -> first pass removes 3 pairs -> ""
assert.equal(sanitize("......"), "");
// "....." (5 dots) -> first pass removes 2 pairs -> "." -> rejected (leading dot)
assert.throws(() => sanitize("....."), /leading\/trailing dots not allowed/);
});
it("rejects leading dots in filenames", () => {
assert.throws(() => sanitize(".hidden"), /leading\/trailing dots not allowed/);
assert.throws(() => sanitize(".env"), /leading\/trailing dots not allowed/);
});
it("rejects trailing dots in filenames", () => {
assert.throws(() => sanitize("file."), /leading\/trailing dots not allowed/);
});
});
describe("buildKey", () => {
it("builds key with userId and filename", () => {
assert.equal(
buildKey("telegram_12345", "IDENTITY.md"),
"telegram_12345/IDENTITY.md",
);
});
it("sanitizes userId in key", () => {
assert.equal(
buildKey("telegram:12345", "notes.md"),
"telegram_12345/notes.md",
);
});
it("returns prefix with trailing slash when no filename", () => {
assert.equal(buildKey("telegram_12345"), "telegram_12345/");
});
it("sanitizes both userId and filename", () => {
assert.equal(
buildKey("../admin", "../../etc/passwd"),
"_admin/__etc_passwd",
);
});
});
describe("validateUserId", () => {
it("accepts valid telegram namespace", () => {
assert.doesNotThrow(() => validateUserId("telegram_123456789"));
});
it("accepts valid slack namespace", () => {
assert.doesNotThrow(() => validateUserId("slack_sen-outlook"));
});
it("accepts valid discord namespace", () => {
assert.doesNotThrow(() => validateUserId("discord_123456789012345678"));
});
it("accepts valid slack namespace with uppercase ID", () => {
assert.doesNotThrow(() => validateUserId("slack_U0AGD41CBGS"));
});
it("rejects empty userId", () => {
// validateUserId calls process.exit, so we mock it
const originalExit = process.exit;
let exitCode = null;
process.exit = (code) => {
exitCode = code;
throw new Error("process.exit called");
};
try {
assert.throws(() => validateUserId(""), /process\.exit/);
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
it("rejects default-user", () => {
const originalExit = process.exit;
let exitCode = null;
process.exit = (code) => {
exitCode = code;
throw new Error("process.exit called");
};
try {
assert.throws(() => validateUserId("default-user"), /process\.exit/);
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
it("rejects arbitrary namespace without channel prefix", () => {
const originalExit = process.exit;
let exitCode = null;
process.exit = (code) => {
exitCode = code;
throw new Error("process.exit called");
};
try {
assert.throws(() => validateUserId("my-custom-id"), /process\.exit/);
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
it("rejects path traversal attempts", () => {
const originalExit = process.exit;
let exitCode = null;
process.exit = (code) => {
exitCode = code;
throw new Error("process.exit called");
};
try {
assert.throws(() => validateUserId("../other_user"), /process\.exit/);
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
});
#!/usr/bin/env node
/**
* delete_user_file — Delete a file from a user's S3-namespaced storage.
* Usage: node delete.js <user_id> <filename>
*/
const { S3Client, DeleteObjectCommand } = require("@aws-sdk/client-s3");
const { BUCKET, REGION, buildKey, validateUserId, validateBucket } = require("./common");
async function main() {
const userId = process.argv[2];
const filename = process.argv[3];
validateUserId(userId);
validateBucket();
if (!filename) {
console.error("Error: filename argument is required.");
process.exit(1);
}
const key = buildKey(userId, filename);
const client = new S3Client({ region: REGION });
await client.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
}));
console.log(`File deleted: ${key}`);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
#!/usr/bin/env node
/**
* list_user_files — List files in a user's S3-namespaced storage.
* Usage: node list.js <user_id>
*/
const { S3Client, ListObjectsV2Command } = require("@aws-sdk/client-s3");
const {
BUCKET,
REGION,
buildKey,
validateUserId,
validateBucket,
} = require("./common");
async function main() {
const userId = process.argv[2];
validateUserId(userId);
validateBucket();
const prefix = buildKey(userId);
const client = new S3Client({ region: REGION });
const response = await client.send(
new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
MaxKeys: 1000,
}),
);
const files = (response.Contents || []).map((obj) => {
const name = obj.Key.replace(prefix, "");
const sizeKB = (obj.Size / 1024).toFixed(1);
const modified = obj.LastModified.toISOString().split("T")[0];
return `- ${name} (${sizeKB} KB, ${modified})`;
});
if (files.length === 0) {
console.log("No files stored for this user.");
} else {
let output = `Files for ${userId}:\n${files.join("\n")}`;
if (response.IsTruncated) {
output += `\n(truncated — more than ${files.length} files exist)`;
}
console.log(output);
}
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
{
"name": "s3-user-files",
"version": "1.0.0",
"description": "Per-user persistent file storage backed by AWS S3",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.0.0",
"@aws-sdk/s3-request-presigner": "^3.0.0"
}
}
#!/usr/bin/env node
/**
* presign_user_file — Generate a SigV4 presigned URL for a user's S3 file.
* Usage: node presign.js <user_id> <filename> [expires_in_seconds]
*
* AWS SDK v3 uses SigV4 by default, which is required for SSE-KMS encrypted buckets.
*/
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { BUCKET, REGION, buildKey, validateUserId, validateBucket } = require("./common");
async function main() {
const userId = process.argv[2];
const filename = process.argv[3];
const expiresIn = parseInt(process.argv[4], 10) || 3600;
validateUserId(userId);
validateBucket();
if (!filename) {
console.error("Error: filename argument is required.");
process.exit(1);
}
const MAX_EXPIRES = 7 * 24 * 3600; // 7 days (S3 max for IAM user; STS creds may be shorter)
if (expiresIn < 1 || expiresIn > MAX_EXPIRES) {
console.error(`Error: expires_in must be between 1 and ${MAX_EXPIRES} seconds.`);
process.exit(1);
}
const key = buildKey(userId, filename);
const client = new S3Client({ region: REGION });
const url = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
{ expiresIn },
);
console.log(url);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
#!/usr/bin/env node
/**
* read_user_file — Read a file from a user's S3-namespaced storage.
* Usage: node read.js <user_id> <filename>
*/
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const { BUCKET, REGION, buildKey, validateUserId, validateBucket, streamToString } = require("./common");
async function main() {
const userId = process.argv[2];
const filename = process.argv[3];
validateUserId(userId);
validateBucket();
if (!filename) {
console.error("Error: filename argument is required.");
process.exit(1);
}
const key = buildKey(userId, filename);
const client = new S3Client({ region: REGION });
try {
const response = await client.send(new GetObjectCommand({
Bucket: BUCKET,
Key: key,
}));
const content = await streamToString(response.Body);
console.log(content);
} catch (err) {
if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
console.log(`File not found: ${key}`);
} else {
throw err;
}
}
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
#!/usr/bin/env node
/**
* write_user_file — Write content to a user's S3-namespaced file.
* Usage: node write.js <user_id> <filename> <content...>
*/
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const {
BUCKET,
REGION,
buildKey,
validateUserId,
validateBucket,
} = require("./common");
/**
* Read all data from stdin as a string.
*/
function readStdin() {
return new Promise((resolve, reject) => {
const chunks = [];
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => resolve(chunks.join("")));
process.stdin.on("error", reject);
});
}
async function main() {
const userId = process.argv[2];
const filename = process.argv[3];
// Read content from argv or stdin (--stdin flag). Stdin avoids OS ARG_MAX limits.
const argContent = process.argv.slice(4).join(" ");
const content = argContent === "--stdin" ? await readStdin() : argContent;
validateUserId(userId);
validateBucket();
if (!filename) {
console.error("Error: filename argument is required.");
process.exit(1);
}
if (!content) {
console.error("Error: content argument is required.");
process.exit(1);
}
const MAX_CONTENT_BYTES = 1 * 1024 * 1024; // 1 MB
if (Buffer.byteLength(content, "utf-8") > MAX_CONTENT_BYTES) {
console.error("Error: content exceeds maximum allowed size (1 MB).");
process.exit(1);
}
const key = buildKey(userId, filename);
const client = new S3Client({ region: REGION });
await client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: content,
ContentType: "text/plain; charset=utf-8",
}),
);
console.log(`File written: ${key} (${content.length} bytes)`);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
Related skills
FAQ
How is data isolated between users?
Files are isolated per user via an S3 key prefix of user_id/filename, so there is no cross-user data leakage.
How should presigned URLs be generated?
Always use the presign_user_file tool, not aws s3 presign, which may produce SigV2 signatures that fail on KMS-encrypted buckets.