S3 API
g7 buckets speak the S3 API. Point any S3 client at https://g7.tissue.systems with a bucket's credentials and it can list, read, write, and delete objects. The AWS CLI, boto3, rclone, and the S3 SDK for your language all work without a plugin or a compatibility layer.
This is the access path for everything outside a Cell: CI jobs pushing build artifacts, a laptop pulling backups, a media pipeline that already speaks S3. Inside a Cell, use the g7 binding instead. It's faster and needs no credentials.
Endpoint
| Endpoint URL | https://g7.tissue.systems |
| Region | tissue |
| Addressing | Path-style (https://g7.tissue.systems/<bucket>/<key>) |
| Authentication | AWS Signature Version 4, in request headers or presigned query strings |
Every client needs the endpoint URL and the region set explicitly. Most also need path-style addressing; the AWS CLI and boto3 pick it up on their own once endpoint_url is set, and the examples below show the setting for clients that do not.
Get credentials for a bucket
Each bucket has its own access key. A key unlocks exactly one bucket. It can't see or touch anything else in your account, so it's safe to hand a key to a single CI job or a single machine.
From the dashboard: open the bucket, then Show credentials.
From the API, with a token carrying the buckets:write scope:
curl -X POST https://api.tissue.systems/v1/buckets/avatars/s3-credentials \
-H "Authorization: Bearer $TISSUE_TOKEN"{
"bucket": "avatars",
"accessKeyId": "GKa1b2c3d4e5f60718293a4b5c",
"secretAccessKey": "…",
"region": "tissue",
"endpoint": "https://g7.tissue.systems"
}The call returns the bucket's existing key rather than minting a new one, so it is safe to run twice. Treat the secret like a password: it can write and delete every object in the bucket. Rotating a key isn't self-serve yet, so contact support if one leaks.
Install the AWS CLI
The examples use AWS CLI v2. Version 1 is unmaintained; if aws --version reports aws-cli/1.x, upgrade.
macOS
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /Or with Homebrew:
brew install awscliLinux (x86_64)
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/installOn ARM machines (a Raspberry Pi, an Ampere or Graviton VM), substitute awscli-exe-linux-aarch64.zip.
Windows
msiexec.exe /i https://awscliv2.amazonaws.com/AWSCLIV2.msiConfirm the install:
aws --versionaws-cli/2.35.7 Python/3.14.6 Darwin/25.5.0 source/arm64Configure the AWS CLI
Environment variables suit CI jobs and throwaway shells:
export AWS_ACCESS_KEY_ID=GKa1b2c3d4e5f60718293a4b5c
export AWS_SECRET_ACCESS_KEY=…
export AWS_DEFAULT_REGION=tissue
export AWS_ENDPOINT_URL=https://g7.tissue.systems
aws s3 ls s3://avatars/A named profile suits a workstation you use every day. In ~/.aws/config:
[profile tissue]
region = tissue
endpoint_url = https://g7.tissue.systemsIn ~/.aws/credentials:
[tissue]
aws_access_key_id = GKa1b2c3d4e5f60718293a4b5c
aws_secret_access_key = …Then pass --profile tissue to any command:
aws --profile tissue s3 ls s3://avatars/A profile per bucket keeps the one-key-one-bucket boundary visible in the command you type. If you would rather not configure anything, --endpoint-url works on every invocation:
aws --endpoint-url https://g7.tissue.systems s3 ls s3://avatars/AWS CLI examples
The commands below assume AWS_ENDPOINT_URL and credentials are already in the environment.
List objects
aws s3 ls s3://avatars/
aws s3 ls s3://avatars/portraits/ --recursiveUpload and download
aws s3 cp ./photo.jpg s3://avatars/portraits/alice.jpg
aws s3 cp s3://avatars/portraits/alice.jpg ./alice.jpgObjects larger than 8 MB are split into parts and uploaded in parallel automatically, so a multi-gigabyte file is one cp like any other.
Set the content type so browsers render the object instead of downloading it:
aws s3 cp ./index.html s3://site-assets/index.html \
--content-type text/html \
--cache-control "public, max-age=3600"Sync a directory, transferring only what changed:
aws s3 sync ./dist s3://site-assets/ --delete
aws s3 sync s3://site-assets/ ./restoreMove, copy, and delete
aws s3 mv s3://avatars/old.jpg s3://avatars/archive/old.jpg
aws s3 rm s3://avatars/portraits/alice.jpg
aws s3 rm s3://avatars/thumbnails/ --recursivePresign a URL so someone without credentials can fetch one object:
aws s3 presign s3://avatars/portraits/alice.jpg --expires-in 3600Lower-level calls through s3api when you need the raw response:
aws s3api head-object --bucket avatars --key portraits/alice.jpg
aws s3api list-objects-v2 --bucket avatars --prefix portraits/ --max-keys 100
aws s3api get-object --bucket avatars --key video.mp4 --range 'bytes=0-1048575' first-mb.binPython
Install boto3:
pip install boto3Build a client once and reuse it:
import boto3
from botocore.client import Config
s3 = boto3.client(
"s3",
endpoint_url="https://g7.tissue.systems",
region_name="tissue",
aws_access_key_id="GKa1b2c3d4e5f60718293a4b5c",
aws_secret_access_key="…",
config=Config(s3={"addressing_style": "path"}),
)addressing_style is explicit here because a client that falls back to virtual-host style would try to resolve avatars.g7.tissue.systems, which does not exist.
Write and read an object
s3.put_object(
Bucket="avatars",
Key="portraits/alice.jpg",
Body=open("photo.jpg", "rb"),
ContentType="image/jpeg",
)
obj = s3.get_object(Bucket="avatars", Key="portraits/alice.jpg")
data = obj["Body"].read()Upload and download files, with parts and retries handled for you:
s3.upload_file("backup.tar.gz", "backups", "2026-08-09/backup.tar.gz")
s3.download_file("backups", "2026-08-09/backup.tar.gz", "restore.tar.gz")List every object, paginating past the 1000-key response limit:
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket="avatars", Prefix="portraits/"):
for obj in page.get("Contents", []):
print(obj["Key"], obj["Size"])Presign a URL for a browser upload or a one-off download:
download = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "avatars", "Key": "portraits/alice.jpg"},
ExpiresIn=3600,
)
upload = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "avatars", "Key": "incoming/photo.jpg"},
ExpiresIn=900,
)A presigned URL carries its own signature, so the holder needs no credentials and no S3 client. A plain PUT or GET is enough. It stops working when it expires.
Copy and delete
s3.copy_object(
Bucket="avatars",
Key="archive/alice.jpg",
CopySource="avatars/portraits/alice.jpg",
)
s3.delete_object(Bucket="avatars", Key="portraits/alice.jpg")Other S3 clients
rclone: add a remote to ~/.config/rclone/rclone.conf:
[tissue]
type = s3
provider = Other
endpoint = https://g7.tissue.systems
region = tissue
force_path_style = true
access_key_id = GKa1b2c3d4e5f60718293a4b5c
secret_access_key = …rclone copy ./dist tissue:site-assets/
rclone sync tissue:backups/ ./restoreAny other client needs the same four things: the endpoint URL, region tissue, path-style addressing, and the bucket's key pair.
How this differs from Amazon S3
A key names its bucket. The bucket segment in a request URL is not what selects the bucket; the access key is. Two consequences:
aws s3 lswith no bucket returns nothing. There is no ListBuckets operation, because a key can only ever see one bucket.- Typing the wrong bucket name does not fail; the request lands on the key's own bucket. A key cannot reach another bucket by any spelling, so this is a boundary rather than a leak, but a typo in a script is silent.
Path-style addressing only. https://g7.tissue.systems/<bucket>/<key>. Virtual-host style (<bucket>.g7.tissue.systems) does not resolve.
The region is tissue. It is part of the signature, so a client configured for us-east-1 will sign against the wrong scope with some SDKs.
Signatures expire in 15 minutes. A request signed more than 15 minutes away from server time is rejected. If everything returns 403 on a machine that worked yesterday, check its clock first.
100 MiB per request. A single PUT is capped at 100 MiB. Larger objects go up as multipart uploads, where the cap applies per part rather than to the object. The AWS CLI and boto3 do this for you above 8 MB, and there is no ceiling on the finished object.
Bucket-level configuration is not implemented. Versioning, tagging, and object ACLs return NotImplemented. Bucket visibility is a g7 setting rather than an S3 ACL: ribo bucket set <name> --public or --private, described in Managing buckets.
See also
- Managing buckets: creating buckets,
ribofile transfer, public access - g7 API reference: the binding API for use inside a Cell
- g7 limitations: what g7 does not do