Multi-tenant LINE agents: 5 hard lessons from BeauBot
One channel per store, prompt isolation, shared context — what we learned turning a ReAct agent into a multi-tenant SaaS.
BeauBot is a LINE AI customer-service SaaS for beauty studios. The first version served one nail salon — a great code experience: hardcoded LINE Channel ID, hardcoded prompt, hours in a config file. Scaling to many studios surfaced 5 structural problems. Notes for whoever follows.
Lesson 1: Per-store channels — where does the token live
LINE Messaging API Channel Access Tokens are long-lived (30 days or non-expiring). Each store's token must be encrypted-at-rest. My choice:
// Channel tokens encrypted with PG's pgcrypto extension
type Tenant struct {
ID string `db:"id"`
Name string `db:"name"`
LineChannelID string `db:"line_channel_id"`
// Never store raw token. Store pgcrypto-encrypted base64.
LineChannelTokenEnc string `db:"line_channel_token_enc"`
OperatingHours string `db:"operating_hours"` // JSON
}
// Insert:
// INSERT INTO tenants (..., line_channel_token_enc)
// VALUES (..., pgp_sym_encrypt($1, $masterKey))
//
// Read:
// SELECT pgp_sym_decrypt(line_channel_token_enc::bytea, $masterKey) FROM tenants$masterKey is read from env (AES_MASTER_KEY), never persisted.
Lesson 2: Prompt isolation vs shared template
Each store has a different tone: a young nail salon wants playful, a traditional barber wants formal. But the prompt structure is mostly the same (role + tool list + examples).
I started by giving each store a full prompt. Updating the base flow meant editing every tenant — disaster.
Switched to a layered prompt:
[base layer — shared]
You are a beauty-studio LINE customer-service agent. Available tools:
- query_services: list services
- check_availability: list slots
- create_booking: create a booking
...
[tenant layer — overridden per store]
store name: {{store_name}}
tone: {{tone}} # casual | formal | friendly
custom rules: {{custom_rules}}
example conversation: {{examples}}Edit the base, every store benefits. Edit a tenant layer, only that store changes.
Lesson 3: tenant context for tools
A ReAct agent's tool calls need to know "which store am I?". If a tool signature is func queryServices(category string), it has no way to know whose services to query.
Solution: partial-apply tenant ID at tool registration:
func buildTools(tenantID string, db *sql.DB) []Tool {
return []Tool{
{
Name: "query_services",
Description: "List all services for this store",
// tenantID is closed over; the LLM never sees it
Run: func(args map[string]any) (string, error) {
rows, err := db.Query(
"SELECT name, price FROM services WHERE tenant_id = $1",
tenantID,
)
// ...
},
},
}
}The LLM decides "call query_services". Tenant isolation is enforced in code, never trusted to the LLM's reasoning.
Lesson 4: per-tenant rate limit
OpenAI API has account-wide RPM / TPM limits. One store flooded with messages (a campaign launch) shouldn't throttle every other store.
Per-tenant token bucket:
import "golang.org/x/time/rate"
var tenantLimiters sync.Map // map[tenantID]*rate.Limiter
func getLimiter(tenantID string) *rate.Limiter {
if l, ok := tenantLimiters.Load(tenantID); ok {
return l.(*rate.Limiter)
}
// default: 5 req/sec, burst 10
l := rate.NewLimiter(5, 10)
tenantLimiters.Store(tenantID, l)
return l
}Lesson 5: model cost control
GPT-4o costs ~US$ 0.015 per reasoning step. 300 messages/day per store × 100 stores = 30,000 calls/day = ~US$ 450/day. Won't fly.
Switched to a two-stage intent classification:
1. nano stage (GPT-4o-mini)
classify intent: trivia / booking / complaint / other
↓
2. dispatch by class:
trivia → reply with nano directly (US$ 0.0003/call)
booking → escalate to ReAct Agent on 4o
complaint → hand off to human60% cost reduction, no perceptible UX change.
Lessons
Multi-tenant isn't just "add a tenant_id column". Prompts, tools, rate limits, cost control — every layer needs per-tenant design. Otherwise one bad store breaks everyone.
If you're building multi-tenant AI SaaS, list these 5 issues up front. You'll save at least 2 months of refactoring.