forgestack stacks
Documentation

Guides

Four things, in the order you will need them: connecting a cloud account, deploying into it, filling the knowledge base your model answers from, and calling that model over the API. Every step is copyable on its right.

Connection

ForgeStack deploys into your cloud account, so the first thing it needs is permission to do that. Every option in this section ends the same way — a verified credentials link on the Cloud & region screen — but they differ in what gets stored and how much setup they take. Pick one and follow it top to bottom.

Every AWS command here runs in AWS CloudShell — a browser terminal already signed in as whoever you are in the console. Nothing to install.

Open CloudShell

Amazon Web Services

Three ways in, in the order we recommend them. All three grant the same thing: the ability to create and tear down ForgeStack’s own resources — the fx-* and forgestack-* names — and nothing else in your account.

Preselected from your timezone. Every snippet below updates to match.

Sign in with AWS (SSO)

Recommended

You sign in at your own AWS access portal and approve the request. ForgeStack holds a short-lived token — no password or key is ever stored here.

Best for: Almost everyone. The only option that stores no long-lived secret.

01Set your details

Change EMAIL to the address you want to sign in with, then run.

REGION="us-east-1"
EMAIL="you@example.com"
USER_NAME="forgestack-admin"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "Account $ACCOUNT_ID · region $REGION"
02Turn on Identity Center

Says "already exists" if it is on — that is fine. An AWS Organizations management account cannot do this from the CLI; enable it once in the console instead.

aws sso-admin create-instance --name forgestack --region "$REGION" 2>/dev/null \
  || echo "Already enabled (or an Organizations management account)."

until [ "$(aws sso-admin list-instances --region "$REGION" \
  --query 'Instances[0].Status' --output text 2>/dev/null)" = "ACTIVE" ]; do
  echo "  waiting for Identity Center to come up…"; sleep 10
done

INSTANCE_ARN=$(aws sso-admin list-instances --region "$REGION" \
  --query 'Instances[0].InstanceArn' --output text)
STORE_ID=$(aws sso-admin list-instances --region "$REGION" \
  --query 'Instances[0].IdentityStoreId' --output text)
echo "OK · $INSTANCE_ARN"
03Create your sign-in user

Errors with "already exists" if you have run it before — skip to the next step if so. The CLI cannot set a password; step 5 covers that.

USER_ID=$(aws identitystore create-user --region "$REGION" \
  --identity-store-id "$STORE_ID" \
  --user-name "$USER_NAME" --display-name "ForgeStack Admin" \
  --name GivenName=ForgeStack,FamilyName=Admin \
  --emails Value="$EMAIL",Type=work,Primary=true \
  --query UserId --output text)
echo "User $USER_ID"
04Give that user access to this account

AdministratorAccess is the simplest thing that works. The deploy itself only needs CloudFormation, EC2, IAM, S3, SSM and Lambda if you would rather narrow it later.

PS_ARN=$(aws sso-admin create-permission-set --region "$REGION" \
  --instance-arn "$INSTANCE_ARN" --name ForgeStackDeploy \
  --description "Deploys ForgeStack stacks" --session-duration PT8H \
  --query 'PermissionSet.PermissionSetArn' --output text)

aws sso-admin attach-managed-policy-to-permission-set --region "$REGION" \
  --instance-arn "$INSTANCE_ARN" --permission-set-arn "$PS_ARN" \
  --managed-policy-arn arn:aws:iam::aws:policy/AdministratorAccess

aws sso-admin create-account-assignment --region "$REGION" \
  --instance-arn "$INSTANCE_ARN" --target-id "$ACCOUNT_ID" --target-type AWS_ACCOUNT \
  --permission-set-arn "$PS_ARN" --principal-type USER --principal-id "$USER_ID"
echo "Access granted."
05Copy the portal URL and switch on email OTP

No AWS API returns the access portal URL, so this last step is manual. On the settings page, copy the "AWS access portal URL" into ForgeStack, and under Authentication switch on "Send email OTP" so your user can set a password at first sign-in.

Open Identity Center settings →
echo "https://us-east-1.console.aws.amazon.com/singlesignon/home?region=us-east-1#!/settings"

Access keys

A dedicated IAM user with a scoped policy. Works on any account, including one with no Identity Center at all.

Best for: Standalone accounts, or when Identity Center is not an option.

These are long-lived credentials. Stored encrypted here until you delete them in IAM — nothing rotates them automatically.

01Create a dedicated IAM user

Never hand over your root credentials. This user exists only to deploy ForgeStack stacks and can be deleted at any time.

USER_NAME="forgestack-deploy"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

aws iam get-user --user-name "$USER_NAME" >/dev/null 2>&1 \
  || aws iam create-user --user-name "$USER_NAME" --tags Key=ManagedBy,Value=ForgeStack

echo "Account $ACCOUNT_ID · user $USER_NAME"
02Grant the deploy permissions

Scoped to this account and to ForgeStack's own resource names — the fx-* and forgestack-* prefixes. It cannot touch anything else you run. Safe to re-run; it replaces the policy in place.

USER_NAME="forgestack-deploy"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

if [ -z "$ACCOUNT_ID" ]; then
  echo "Could not read your account ID — are you signed in?"
else
cat > /tmp/forgestack-deploy-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFormationStackLifecycle",
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateStack",
        "cloudformation:UpdateStack",
        "cloudformation:DeleteStack",
        "cloudformation:ContinueUpdateRollback",
        "cloudformation:DescribeStacks",
        "cloudformation:DescribeStackEvents",
        "cloudformation:DescribeStackResources",
        "cloudformation:GetTemplate",
        "cloudformation:ListStacks"
      ],
      "Resource": "arn:aws:cloudformation:*:$ACCOUNT_ID:stack/forgestack-*/*"
    },
    {
      "Sid": "ManageForgeStackIamResources",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:DeleteRole",
        "iam:GetRole",
        "iam:TagRole",
        "iam:UntagRole",
        "iam:PutRolePolicy",
        "iam:DeleteRolePolicy",
        "iam:GetRolePolicy",
        "iam:ListRolePolicies",
        "iam:ListAttachedRolePolicies",
        "iam:ListInstanceProfilesForRole",
        "iam:CreateInstanceProfile",
        "iam:DeleteInstanceProfile",
        "iam:GetInstanceProfile",
        "iam:TagInstanceProfile",
        "iam:AddRoleToInstanceProfile",
        "iam:RemoveRoleFromInstanceProfile",
        "iam:PassRole"
      ],
      "Resource": [
        "arn:aws:iam::$ACCOUNT_ID:role/fx-*",
        "arn:aws:iam::$ACCOUNT_ID:instance-profile/fx-*"
      ]
    },
    {
      "Sid": "AttachForgeStackManagedPolicies",
      "Effect": "Allow",
      "Action": [
        "iam:AttachRolePolicy",
        "iam:DetachRolePolicy"
      ],
      "Resource": "arn:aws:iam::$ACCOUNT_ID:role/fx-*",
      "Condition": {
        "ArnEquals": {
          "iam:PolicyARN": [
            "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
            "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
            "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
          ]
        }
      }
    },
    {
      "Sid": "ManageForgeStackSsmParameters",
      "Effect": "Allow",
      "Action": [
        "ssm:PutParameter",
        "ssm:GetParameter",
        "ssm:GetParameters",
        "ssm:DeleteParameter",
        "ssm:AddTagsToResource",
        "ssm:RemoveTagsFromResource",
        "ssm:ListTagsForResource"
      ],
      "Resource": "arn:aws:ssm:*:$ACCOUNT_ID:parameter/forgestack/*"
    },
    {
      "Sid": "ReadPublicAmiParameters",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:GetParameters"
      ],
      "Resource": "arn:aws:ssm:*::parameter/aws/service/*"
    },
    {
      "Sid": "RemoveForgeStackLambdaLogGroups",
      "Effect": "Allow",
      "Action": [
        "logs:DeleteLogGroup"
      ],
      "Resource": "arn:aws:logs:*:$ACCOUNT_ID:log-group:/aws/lambda/fx-*"
    },
    {
      "Sid": "ListLogGroups",
      "Effect": "Allow",
      "Action": [
        "logs:DescribeLogGroups"
      ],
      "Resource": "arn:aws:logs:*:$ACCOUNT_ID:log-group:*"
    },
    {
      "Sid": "ManageForgeStackLambdaFunctions",
      "Effect": "Allow",
      "Action": "lambda:*",
      "Resource": "arn:aws:lambda:*:$ACCOUNT_ID:function:fx-*"
    },
    {
      "Sid": "ManageForgeStackKbBucket",
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::fx-*",
        "arn:aws:s3:::fx-*/*"
      ]
    },
    {
      "Sid": "ManageForgeStackComputeNetworkAndGateway",
      "Effect": "Allow",
      "Action": [
        "ec2:Describe*",
        "ec2:RunInstances",
        "ec2:TerminateInstances",
        "ec2:StopInstances",
        "ec2:StartInstances",
        "ec2:ModifyInstanceAttribute",
        "ec2:AssociateIamInstanceProfile",
        "ec2:ReplaceIamInstanceProfileAssociation",
        "ec2:CreateTags",
        "ec2:DeleteTags",
        "ec2:CreateVpc",
        "ec2:DeleteVpc",
        "ec2:ModifyVpcAttribute",
        "ec2:CreateSubnet",
        "ec2:DeleteSubnet",
        "ec2:ModifySubnetAttribute",
        "ec2:CreateInternetGateway",
        "ec2:DeleteInternetGateway",
        "ec2:AttachInternetGateway",
        "ec2:DetachInternetGateway",
        "ec2:CreateRouteTable",
        "ec2:DeleteRouteTable",
        "ec2:CreateRoute",
        "ec2:DeleteRoute",
        "ec2:ReplaceRoute",
        "ec2:AssociateRouteTable",
        "ec2:DisassociateRouteTable",
        "ec2:CreateVpcEndpoint",
        "ec2:DeleteVpcEndpoints",
        "ec2:ModifyVpcEndpoint",
        "ec2:CreateSecurityGroup",
        "ec2:DeleteSecurityGroup",
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:RevokeSecurityGroupIngress",
        "ec2:AuthorizeSecurityGroupEgress",
        "ec2:RevokeSecurityGroupEgress",
        "route53:AssociateVPCWithHostedZone",
        "apigateway:*"
      ],
      "Resource": "*"
    }
  ]
}
EOF
  POLICY_ARN="arn:aws:iam::$ACCOUNT_ID:policy/forgestack-deploy-policy"
  if aws iam get-policy --policy-arn "$POLICY_ARN" >/dev/null 2>&1; then
    # Re-run: publish a new default version, first pruning old ones (AWS keeps at most 5).
    for V in $(aws iam list-policy-versions --policy-arn "$POLICY_ARN" \
        --query 'Versions[?!IsDefaultVersion].VersionId' --output text); do
      aws iam delete-policy-version --policy-arn "$POLICY_ARN" --version-id "$V"
    done
    aws iam create-policy-version --policy-arn "$POLICY_ARN" --set-as-default \
      --policy-document file:///tmp/forgestack-deploy-policy.json >/dev/null
  else
    aws iam create-policy --policy-name forgestack-deploy-policy \
      --policy-document file:///tmp/forgestack-deploy-policy.json \
      --tags Key=ManagedBy,Value=ForgeStack >/dev/null
  fi \
    && aws iam attach-user-policy --user-name "$USER_NAME" --policy-arn "$POLICY_ARN" \
    && echo "Policy attached to $USER_NAME (account $ACCOUNT_ID)." \
    || echo "Policy NOT attached — see the error above."
  rm -f /tmp/forgestack-deploy-policy.json
fi
03Create the access key

Prints AccessKeyId and SecretAccessKey once — copy both into ForgeStack now, the secret is never shown again. A user tops out at two access keys; delete an old one first if this fails with LimitExceeded.

aws iam create-access-key --user-name forgestack-deploy
04Verify the key works

Run this with the new key configured to confirm it authenticates before you paste it in. It should print the ARN of the forgestack-deploy user.

AWS_ACCESS_KEY_ID="AKIA…" \
AWS_SECRET_ACCESS_KEY="…" \
aws sts get-caller-identity

Role ARN (cross-account)

A scoped IAM role in your account that trusts ForgeStack under an External ID — the same mechanism Datadog and Snowflake use.

Best for: Production and cross-account setups where no secret should change hands.

01Launch the bootstrap stack

The Connect screen's "Launch in AWS Console" button opens CloudFormation with this template and your External ID pre-filled. It creates one IAM role named forgestack-deployer, trusting ForgeStack's platform account under that External ID.

02Wait for CREATE_COMPLETE

Takes under a minute. The stack creates the role and nothing else.

aws cloudformation describe-stacks \
  --stack-name forgestack-bootstrap \
  --query 'Stacks[0].StackStatus' --output text
03Confirm the role exists

Should print the role ARN, ending in :role/forgestack-deployer.

aws iam get-role --role-name forgestack-deployer \
  --query 'Role.Arn' --output text
04Copy your account ID into ForgeStack

Only the 12-digit account ID is needed — ForgeStack builds the full ARN itself from the fixed role name. It is the number in the account menu, top right of the console.

aws sts get-caller-identity --query Account --output text

Google Cloud

Run these with the gcloud CLI against the project you want the stack to live in.

Service account key

A deployer service account with compute and storage admin, authorised by a JSON key.

Best for: The only supported GCP path today.

Google Cloud support is not tested end to end yet — the AWS path is the supported one.

01Create the service account

GCP has no cross-account role-assumption model as simple as AWS's, so this path uses a service-account key. ForgeStack stores it encrypted.

gcloud iam service-accounts create forgestack-deployer --project=<PROJECT_ID>
02Grant it compute and storage admin

compute.admin provisions the inference VM and its firewall rule; storage.admin holds the knowledge base bucket.

gcloud projects add-iam-policy-binding <PROJECT_ID> \
  --member=serviceAccount:forgestack-deployer@<PROJECT_ID>.iam.gserviceaccount.com --role=roles/compute.admin

gcloud projects add-iam-policy-binding <PROJECT_ID> \
  --member=serviceAccount:forgestack-deployer@<PROJECT_ID>.iam.gserviceaccount.com --role=roles/storage.admin
03Create and download the key

Paste the contents of key.json into ForgeStack. Treat it like a password.

gcloud iam service-accounts keys create key.json --iam-account=forgestack-deployer@<PROJECT_ID>.iam.gserviceaccount.com
cat key.json
04Verify the key authenticates

Should list your project without error.

gcloud auth activate-service-account --key-file=key.json
gcloud projects describe <PROJECT_ID>

Deploys

Once connected, Apply hands your template to CloudFormation in your account. What the connection lets it build, how to keep that current, and how to read a deploy that did not finish.

Deploy permissions

Every connection method grants the same policy. CloudFormation builds your stack with those permissions, so they have to cover everything the template creates.

01What the policy covers

CloudFormation stacks named forgestack-*; IAM roles and instance profiles named fx-* (and only three AWS managed policies may be attached to them); SSM parameters under /forgestack/ plus read access to AWS's public Ubuntu AMI parameters; Lambda functions and S3 buckets named fx-*, including their objects; and, because EC2 and API Gateway cannot be scoped by name, the stack's own VPC, subnet, internet gateway, route table, VPC endpoints, security groups, instance and HTTP API.

02Check what is attached

Access-key connections: the last command should list every statement, including ReadPublicAmiParameters. NoSuchEntity means the policy was never created.

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
ARN="arn:aws:iam::$ACCOUNT_ID:policy/forgestack-deploy-policy"
aws iam list-attached-user-policies --user-name forgestack-deploy \
  --query 'AttachedPolicies[].PolicyName'
V=$(aws iam get-policy --policy-arn "$ARN" --query Policy.DefaultVersionId --output text)
aws iam get-policy-version --policy-arn "$ARN" --version-id "$V" \
  --query 'PolicyVersion.Document.Statement[].Sid'

03Update it after a ForgeStack update

When the template gains a resource type, the policy gains the actions for it, and an older policy fails with "not authorized to perform". Access keys: paste the "Grant the deploy permissions" step again; it publishes a new version in place. Role ARN: update the bootstrap stack to the current template, as below. Your External ID and the forgestack-deployer role's ARN stay the same.

aws cloudformation update-stack --stack-name forgestack-bootstrap \
  --template-url "https://forgestack-mvp.vercel.app/api/aws/bootstrap-template" \
  --parameters ParameterKey=ExternalId,UsePreviousValue=true \
  --capabilities CAPABILITY_NAMED_IAM

When a deploy fails

A failed apply returns the project to draft but keeps what it built, so the next apply picks up where it stopped. The Apply screen shows what you need to fix first.

01Read the root cause

The provisioning log shows CloudFormation's first failure — the resource and the reason — not the generic "resource(s) failed to create" line. The Infrastructure panel lists every resource in the template as Pending, Creating, Ready or Failed, so you can see how far the stack got.

02After the stack: follow the instance

Once CloudFormation finishes, the EC2 instance reports each boot stage itself — installing Docker, adding swap, fetching the runtime, building the image, starting the container, downloading and loading the model — as "Instance:" rows, with the download percentage. The log panel's Instance tab shows the last lines of its boot script and inference container, refreshed every 30 seconds. A deploy only fails here when the instance reports a failure, the model fails to load, or the instance goes silent for 12 minutes — not because it is slow.

# The same status and log, from CloudShell:
aws ssm get-parameter --name /forgestack/<your-stack-slug>/boot-status \
  --query Parameter.Value --output text
aws s3 cp s3://fx-<your-stack-slug>-kb/boot/boot.log - | tail -n 50

03"not authorized to perform"

The policy is missing an action, almost always because it predates a template change. Update it as described under Deploy permissions, then apply again.

04Fix the cause, then apply again — it resumes

A failed apply does not roll back: everything that was already created is kept, and the next apply continues from there, building only what failed or never started. If the stack built but the model failed to start, only the instance is replaced. The kept resources stay in your account (and keep billing — the VPC endpoint and any instance) until you apply again or delete the stack. This prints CREATE_FAILED while a failed apply is waiting to be resumed.

aws cloudformation describe-stacks --stack-name forgestack-<your-stack-slug> \
  --query 'Stacks[0].StackStatus' --output text

Knowledge base

Your stack answers from two things: the model’s own weights, and the documents you put in its knowledge base. They work differently and are worth keeping straight — retrieval is assembled into the prompt on every request, while a LoRA fine-tune changes the weights once. Both live inside your own cloud account.

Get documents in

Paste, upload or POST text into your stack's knowledge-base bucket. The content lives in your cloud account; ForgeStack keeps only the metadata.

Best for: Anything the model should be able to quote: policies, FAQs, product docs, runbooks.

01Deploy the stack first

Documents are not stored in ForgeStack. They are written to your own stack's S3 bucket (fx-<slug>-kb) and to the running node's index; ForgeStack keeps only the name, byte size and chunk count. So a document can only be added once there is a stack to receive it — before that, POST /documents answers 409 deployment_state_error, and every key-authenticated route answers 409 while the project status is anything but live.

02Upload from the dashboard

Playground → Feed the index takes pasted text or any text-based file — .txt, .md, .csv, .json, .html, .xml, .log, source code. The file goes to your stack as-is: a Lambda there extracts its text (HTML and XML reduced to their text, script/style dropped), stores it under kb/ and indexes it. The cap is 2 MB per file. The panel on the right lists what is indexed and the chunk count per document.

03Or upload over the API

Same limits, same pipeline. Send the file as multipart/form-data (a file field, optional name), or JSON with content as plain text — or as the file's bytes base64-encoded with "encoding":"base64". name defaults to the file name (or doc-<timestamp>.txt) and is truncated to 120 characters; keep its extension, which tells the extractor how to read it. Returns 201 for a new document, 200 with replaced:true when a document with that name already existed.

BASE="https://your-forgestack-host"   # where this dashboard runs
PROJECT_ID="your-project-id"           # the id in the dashboard URL
KEY="fsk_…"                            # from Keys & endpoints

curl -X POST "$BASE/api/$PROJECT_ID/documents" \
  -H "x-api-key: $KEY" \
  -H "content-type: application/json" \
  -d '{"name":"refund-policy.md","content":"Refunds are issued within 14 days…"}'

# Or upload a file — HTML/XML markup is stripped on the stack
curl -X POST "$BASE/api/$PROJECT_ID/documents" \
  -H "x-api-key: $KEY" \
  -F "file=@help-center/refunds.html"

04Sync from Jira, Confluence or anything else

ForgeStack runs no connectors — your own job does that, wherever it suits you (a scheduled Lambda, a CI step, a cron script), and pushes text here. Three rules keep a sync clean. (1) One stable name per source item, e.g. jira/ABC-123.md: pushing the same name again replaces that document in place, so re-running a sync never duplicates. (2) Text-based formats (HTML, XML, Markdown, JSON…) can be sent as they are; binary ones (PDF, Word) must be extracted to text in your job — the stack refuses them with 422. Put the title and link at the top so answers can cite where it came from. (3) Over 2 MB, split it and send each part under its own name (guide.p1.md, guide.p2.md). For items deleted upstream, DELETE the id this API returned for them.

// A scheduled Lambda: recently updated Jira issues → the stack's knowledge base.
const issues = await jiraSearch("updated >= -1h"); // your own Jira client
for (const issue of issues) {
  await fetch(BASE + "/api/" + PROJECT_ID + "/documents", {
    method: "POST",
    headers: { "x-api-key": KEY, "content-type": "application/json" },
    body: JSON.stringify({
      name: "jira/" + issue.key + ".md", // stable name: a re-sync replaces it
      content: "# " + issue.key + ": " + issue.summary + "\nSource: " + issue.url + "\n\n" + issue.text,
    }),
  });
}

05What comes back

chunks is how many retrievable pieces the text split into. note is a sentence written for a human. pending:true means the file was stored but text extraction was still running when the stack had to answer; it becomes searchable within a minute.

{
  "id": "…",
  "object": "kb.document",
  "bucket": "fx-<slug>-kb",
  "key": "kb/refund-policy.md",
  "size": 1284,
  "chunks": 3,
  "replaced": false,
  "synced": true,
  "pending": false,
  "note": "Text extracted and indexed in the stack's knowledge base — 3 retrievable chunks."
}

06List and remove documents

GET returns {object:"list", bucket, documents:[{id, key, size, chunks, created}]}. DELETE takes the id from that list, drops the object from the KB bucket and evicts it from the running node's live index — an S3 delete alone would never reach a chunk already loaded in memory. Returns {ok:true, note}.

curl -H "x-api-key: $KEY" "$BASE/api/$PROJECT_ID/documents"

curl -X DELETE -H "x-api-key: $KEY" \
  "$BASE/api/$PROJECT_ID/documents/<docId>"

07Errors you can hit here

400 invalid_request_error — malformed JSON or multipart body, no content or file, or an empty file. 413 invalid_request_error — over the 2 MB limit. 422 invalid_request_error — the stack found no usable text: a binary file (PDF, Office, image), base64 sent without "encoding":"base64", or under 20 characters once markup is stripped. 409 deployment_state_error — no deployed stack. 502 server_error — the stack could not be reached or failed. 404 — no document with that id on this project.

How the knowledge base reaches the model

Recommended

Upload to answer, end to end — what is stored where, what runs when, and where retrieved text actually enters the prompt.

01Upload writes the document into your KB bucket

On a real AWS deploy the request goes through your stack's API Gateway router, which PUTs the text at kb/<name> in fx-<slug>-kb and simultaneously pushes it straight to the inference container so retrieval is current immediately, without waiting for a reindex.

02An S3 event runs the reindex Lambda

Writing under kb/ fires fx-<slug>-kb-reindex (python3.12). It splits the document on blank lines, encodes each chunk with sentence-transformers (all-MiniLM-L6-v2 by default) and writes one artifact per document to indexes/ in the same bucket: {source, chunks, vectors}. Deleting the document deletes its artifact too, so a removed document cannot be resurrected by the next pull.

03The model node pulls the index every 60 seconds

A background thread on the inference node lists indexes/, loads every artifact and holds the flattened {source, chunk, vector} list in memory. That in-memory list is what retrieval searches — the model itself is never re-trained by an upload.

04A chat request retrieves before it generates

The last user message is the query. The node scores every chunk against it and takes the top 3 that score above zero. Scoring today is term-overlap against the chunk text — the vectors are stored for the embedding-based scorer the node is built to swap in, and the code says so. Retrieval is skipped entirely when RETRIEVAL_ENABLED is false, which is the Configure screen's retrieval toggle.

05Retrieved chunks are appended to the system prompt

The hits are joined as "[<source>] <chunk>" and appended to your configured system prompt under an instruction to answer from that context and to say it does not know when the answer is not in it. The whole thing is then formatted with the model's own chat template. This is the answer to "how does the knowledge base connect to the model": it does not change the weights — it is assembled into the prompt, per request, at answer time.

06The same chunks come back as citations

Every chunk that made it into the prompt is echoed on the response as {document, excerpt} — document being the kb/<name> key, excerpt the first 220 characters. An empty citations array means nothing scored, so the answer came from the model alone.

"citations": [
  { "document": "kb/refund-policy.md", "excerpt": "Refunds are issued within 14 days…" }
]

Fine-tune with LoRA

Real LoRA training on the model node itself, on question/answer examples you write. The resulting adapter hot-swaps into the chat endpoint the moment it finishes.

Best for: Teaching the model a voice, a format or a house answer — not teaching it new facts.

Training runs in the same process that serves chat, against the same copy of the weights. A request that lands mid-job sees the model partway through fine-tuning.

01Know which problem you are solving

Retrieval changes what the model knows for one request. A LoRA fine-tune changes how it answers, permanently, for every request. Facts that change — policies, prices, docs — belong in the knowledge base. Tone, format and house phrasing are what training is for. They are independent: a trained adapter still retrieves, and retrieval still works untrained.

02Write at least three question/answer examples

Three is the floor, enforced in two places — the dashboard's Start training button stays disabled below three filled rows, and the API answers 400 invalid_request_error. Rows with an empty question or answer are dropped before that count is taken.

03Start the job

epochs and lr are optional and best left out: the node sizes epochs, batch, gradient accumulation and sequence length from how many examples you sent, which beats any fixed default. Training runs in-process on the model node — there is no separate training instance — so chat is degraded while it runs.

curl -X POST "$BASE/api/$PROJECT_ID/train" \
  -H "x-api-key: $KEY" \
  -H "content-type: application/json" \
  -d '{"examples":[
        {"question":"How long do refunds take?","answer":"Up to 14 days."},
        {"question":"Do you ship to the EU?","answer":"Yes, from Rotterdam."},
        {"question":"What is the SLA?","answer":"99.9% monthly uptime."}
      ]}'

04Read the start response carefully

A started job answers {"status":"started","examples":3}. A refusal comes back as {"status":"error","error":"…"} — and because the node reports these as HTTP 200, you must check the status field, not just the status code. Refusals include a job already running, a base model still loading, and a preflight that judged this node too small to finish the fine-tune at all.

05Poll for progress

GET the same URL. status is idle, running, cancelling, cancelled, done or error, alongside epoch, totalEpochs, step, loss, durationS and error. The route answers {"status":"idle"} whenever there is no reachable node, so idle means "nothing running", not "never ran". The dashboard polls this every two seconds while a job is running.

curl -H "x-api-key: $KEY" "$BASE/api/$PROJECT_ID/train"

06The adapter hot-swaps in on completion

When training finishes the node rebuilds its serving pipeline on top of the trained adapter in place. The very next call to the chat endpoint is answered by the fine-tuned model — there is no redeploy, no restart and no separate model id to switch to. Cancelling a running job is available from the dashboard; the key-authenticated surface exposes only POST and GET.

Chat API

Once a stack is live it exposes a small key-authenticated surface at /api/{projectId}/… — chat, documents, train and health. The same routes back the playground, so anything the playground can do, your own code can do.

Issue a key and authenticate

One header on every request. Keys are per project, scoped to the endpoints you pick, revocable instantly, and viewable again later from the Keys screen. This key authenticates you to ForgeStack — it is never an AWS credential and grants nothing in your cloud account.

Best for: Every caller — a server, a job, another service. Start here.

A key is a bearer credential for your deployed model. Keep it server-side; anything in a browser bundle or a mobile app is public.

01Issue a key

Keys & endpoints → name it after the caller ("web-frontend") and press Issue a key. The table masks keys to their last four characters; the eye button reveals one in full and the copy button puts it on your clipboard, both fetched only when you ask so keys are not sitting in every page load. Revoking is instant and takes effect on the next request. Issue as many as you like — one per caller makes it obvious what to revoke when something leaks.

02Give it only the scopes it needs

Three independent scopes, ticked at creation: chat (POST /chat), train (POST and GET /train), and documents (POST /documents, DELETE /documents/{id}). A key that has not been given a scope gets 403 permission_error on those routes, naming the scope it lacks. GET /health and GET /documents work with any valid key. Scopes are fixed at creation — to change them, issue a new key and revoke the old one. Keys issued before scopes existed carry all three.

# a chat-only key for a frontend service
curl -X POST "$BASE/api/$PROJECT_ID/train" -H "x-api-key: $CHAT_ONLY_KEY"
# {"error":{"message":"This API key does not have the \"train\" scope. …",
#           "type":"permission_error"}}   → 403

03Send it as x-api-key

On every request, on every route below. Not Authorization, not a bearer token, not a query parameter. A key must begin with fsk_ to be looked up at all.

curl -H "x-api-key: $KEY" "$BASE/api/$PROJECT_ID/health"

04Understand what 401 means here

All three authentication_error cases are 401: a missing or malformed header, a key that is unknown or revoked, and — easy to miss — a valid key used against a different project's URL. The key alone identifies the project; the id in the path is checked for agreement so a copy-paste mistake fails loudly instead of quietly answering for the wrong stack.

05And what 409 means

A perfectly good key still gets 409 deployment_state_error while the project is not live: "Solution is deploying. Endpoints activate when provisioning completes." while deploying, "Solution has not been deployed yet." before that. Treat 409 as retry-later, not as an auth problem.

Call the chat endpoint

Recommended

One POST, one JSON body, one whole response — with the knowledge-base chunks that shaped the answer attached to it.

01POST a message

Three spellings of the prompt are accepted and mean the same thing: a messages array of {role, content}, a bare question string, or a bare message string. With messages, the last entry whose role is user is the retrieval query.

BASE="https://your-forgestack-host"   # where this dashboard runs
PROJECT_ID="your-project-id"           # the id in the dashboard URL
KEY="fsk_…"                            # from Keys & endpoints

curl -X POST "$BASE/api/$PROJECT_ID/chat" \
  -H "x-api-key: $KEY" \
  -H "content-type: application/json" \
  -d '{"messages":[{"role":"user","content":"How long do refunds take?"}]}'

02Or from Python

import requests

r = requests.post(
    f"{BASE}/api/{PROJECT_ID}/chat",
    headers={"x-api-key": KEY},
    json={"messages": [{"role": "user", "content": "How long do refunds take?"}]},
    timeout=200,
)
data = r.json()
print(data["choices"][0]["message"]["content"])
for c in data["citations"]:
    print(" ↳", c["document"])

03Or from Node

const res = await fetch(`${BASE}/api/${PROJECT_ID}/chat`, {
  method: "POST",
  headers: { "x-api-key": KEY, "content-type": "application/json" },
  body: JSON.stringify({ messages: [{ role: "user", content: "How long do refunds take?" }] }),
});
const data = await res.json();
console.log(data.choices[0].message.content, data.citations);

04Request fields

messages / question / message — the prompt, one of the three. max_tokens — cap on the completion, default 512. Temperature is NOT a request field: the value from the Configure screen is applied server-side, so the same project answers with the same sampling for every caller. Streaming is not supported; a response arrives whole.

05Chat history: source, user, conversation_id

Every conversation is saved to your stack's own S3 bucket (chatlogs/, one folder per source) and shows on the dashboard's Chat logs screen. Three optional fields shape it. source names the app the chat comes from ("website", "ios-app"); without it, the calling website's Origin names it, and failing that it is "api". user is your own id for your end user, so the owner can see whose chat it was. conversation_id groups turns: each response returns one — send it back on the next call to continue the same conversation, or leave it out to start a new one. History is kept for a month unless the owner picks another retention for that source.

{
  "messages": [{ "role": "user", "content": "And for digital goods?" }],
  "source": "website",
  "user": "customer-8812",
  "conversation_id": "cmv80e2tq22pm35"
}

06Response fields

choices[0].message.content is the answer. citations lists the KB chunks that were put in the prompt. usage counts tokens as the node's own tokenizer counts them. latency_ms is the node's generation time, so it excludes network time to and from you.

{
  "id": "chatcmpl-…",
  "object": "chat.completion",
  "model": "…",
  "choices": [
    { "message": { "role": "assistant", "content": "Up to 14 days." },
      "finish_reason": "stop" }
  ],
  "citations": [{ "document": "kb/refund-policy.md", "excerpt": "…" }],
  "usage": { "prompt_tokens": 214, "completion_tokens": 12, "total_tokens": 226 },
  "latency_ms": 1840,
  "conversation_id": "cmv80e2tq22pm35"
}

07Allow a generous client timeout

A large model on a CPU-only instance answers in minutes, not seconds. ForgeStack waits up to 180 seconds for the node by default before giving up with 504, so any client timeout shorter than that will fire first and hide the real outcome.

Errors and retries

Which failures are worth retrying, which mean redeploy, and which mean you sent something wrong.

01Every failure has the same shape

error.type classifies it; error.code, when present, is the stable machine-readable reason. Branch on code, never on the message — messages are written to be read by people and are rewritten when they read badly.

{ "error": { "message": "…", "type": "deployment_state_error", "code": "model_loading" } }

02409 · model_loading — worth retrying

The stack is up but the weights are still downloading onto the node. When the node reports progress, a download object rides along on the response as {downloadedBytes, totalBytes, percent}. Poll GET /health, or just retry with backoff; chat starts working when the node reports warm.

03504 · timeout — retry smaller

The node did not answer within the inference timeout. A shorter prompt, a lower max_tokens or a GPU instance type are the three things that actually change this.

04502 · unreachable, bad_response, model_error, unauthorized

unreachable — the node did not accept a connection. bad_response — something answered but not with a completion, usually a stack running an older router, fixed by redeploying. model_error — the model failed to load. unauthorized — the stack rejected ForgeStack's own internal node key, which means the stack was deployed against a key that has since been rotated; redeploy. None of these are about your API key.

05409 with no code — nothing is deployed

"The model node is unreachable. Redeploy or reconnect the stack, then try again." There is no fallback path: your documents live in your stack's bucket, not here, so there is nothing for ForgeStack to answer from on its own.

06400 · invalid_request_error — you sent it wrong

Unparseable JSON, or no prompt under any of the three accepted names. The message names all three so you do not have to come back here to find out which.

The rest of the surface

Everything a key can reach, and the one thing it deliberately cannot.

01POST /api/{projectId}/chat

Grounded chat completion against your model and knowledge base.

02POST · GET /api/{projectId}/documents

Add a knowledge-base document, or list the indexed ones. See the Knowledge base section above for bodies and limits.

03DELETE /api/{projectId}/documents/{docId}

Remove one document by the id that GET returned — from the bucket and from the live index.

04POST · GET /api/{projectId}/train

Start a LoRA fine-tune on your own examples, or poll the running job.

05GET /api/{projectId}/health

Stack, model and knowledge-base status. Useful as a readiness probe: it reports the stack name and region, the model id and node instance type, the KB bucket with its document and byte counts, the indexing pipeline, and uptime_since — null until a deploy has finished.

curl -H "x-api-key: $KEY" "$BASE/api/$PROJECT_ID/health"

06Not part of this surface

Anything under /api/projects/… is the dashboard's own API. It authenticates with your browser session, not with an API key, and is not a public interface — do not build against it.

Head back to your stacks — press Verify access on the Cloud & region screen to finish connecting, or open Keys & endpoints on a live stack to issue a key.