<!-- canonical: https://docs.axelabs.ai/services/magnet -->
<!-- source: content/services/magnet.mdx -->

---
title: Magnet
description: 마케팅 자동화 MCP — Meta Ads, Naver, Threads. 39개 도구, decisions ledger, brand SoT.
---

# Magnet

**한 줄 소개**: 마케팅 팀장의 직무를 통째로 갖는 에이전트. 광고 예산·크리에이티브·소셜 응대·위기 대응을 자동화. 39개 MCP 도구, hash-chained decisions ledger, brand voice 중앙화.

## 정체성

> magnet (당기다) ↔ stream (흐르다)  
> 마케팅이 수요를 *당기면* 운영이 공급을 *흐르게* 한다.

domain 분업: magnet 은 자기 도메인의 외부 채널(광고·소셜) + 의사결정만 소유. 판매·SCM 은 stream MCP 가 owner.

## 기술 스택

| 항목 | 값 |
|---|---|
| 언어 | Python 3.12+ |
| 프레임워크 | mcp[cli] ≥1.27 (FastMCP) |
| DB | PostgreSQL 16 (multi-tenant + RLS) |
| Scheduler | supercronic (Asia/Seoul TZ) |
| 의존성 | pyyaml ≥6, psycopg[binary] ≥3.2, openpyxl ≥3.1 |

## 포트

| 포트 | 용도 |
|---|---|
| 8770 | MCP HTTP/SSE (`magnet-mcp`) |
| 5432 | PostgreSQL (`magnet-postgres` 컨테이너, 127.0.0.1 loopback 만) |

## 39 MCP Tools (6 prefix)

| Prefix | 모듈 | 도구 수 | 주요 |
|---|---|---|---|
| `meta_ads_*` | meta-ads | 5 | get_campaigns, propose/execute_budget_change |
| `meta_capi_*` | meta-ads | 2 | send_event, send_purchase |
| `social_*` | social | 19 | `post_to_fb/ig/threads`, reply_to, like, get_insights |
| `naver_search_ad_*` | naver-ads | 3 | get_campaigns, propose_keyword_bid (skeleton) |
| `marketing_*` | performance + content | 9 | get_ad_spend, daily_brief, weekly_brief, compose_post_prompt |
| `decisions_*` | decisions | 3 | get_decisions, verify_chain_integrity, get_lifecycle |
| `stream_bridge_*` | stream-bridge | 4 | send_purchase_from_stream, handle_inventory_alert |
| `marketing_compose_*` | marketing-compose | 2 | compose_post_prompt, validate_copy |
| `threads_browser_*` | threads-browser-host | 4 | init, like, action, status (호스트 stdio MCP) |

## 디렉토리 구조

```
/Users/axe/magnet/
├── mcp/
│   ├── server.py            ★ 통합 daemon — importlib 로 6 모듈 흡수
│   ├── meta-ads/            Meta Ads + CAPI
│   ├── social/              FB + IG + Threads
│   ├── naver-ads/           네이버 검색광고 (skeleton)
│   ├── performance/         ROAS · CAC · spend 조회
│   ├── content/             콘텐츠 발주 (truvia-marketing-agents 연동)
│   ├── decisions/           decisions ledger 조회·시각화
│   ├── stream-bridge/       stream 신호 receiver (2026-05-11)
│   ├── marketing-compose/   카피·답글 생성 (brand SoT 주입)
│   └── threads-browser-host/ Threads 브라우저 자동화 (별도 stdio MCP)
│
├── scripts/
│   ├── automation/          6+ 자동화 (LaunchAgent 흡수)
│   ├── marketing/           MCP 도구 구현 + CLI
│   ├── db/                  DB 연결 + sync
│   ├── admin/               운영자 CLI
│   ├── decisions/           decisions 분석
│   └── entity/              멀티 엔터티 레지스트리
│
├── brand/                   ★ 마케팅 identity SoT
│   ├── personas/operator.yaml    화자 정체성
│   └── voice/                    채널별 톤
│       ├── _global.yaml          전 채널 (광고법·금기)
│       ├── threads.yaml
│       ├── instagram.yaml
│       └── fb.yaml
│
├── data/
│   ├── decisions/realchoice.jsonl   ★ hash-chained ledger (append-only)
│   ├── reports/YYYY-MM-DD.md
│   ├── pending-monitor-actions/
│   └── pending-user-actions.md
│
├── sql/                     스키마 1,300 LOC (10 파일)
│   ├── 000_schema.sql       campaign_metrics_daily, posts, threads_post_snapshots
│   ├── 050_multi_tenant_baseline.sql   ★ RLS + tenant_id
│   ├── 060_threads_clone.sql           Threads snapshot (사고 회복)
│   ├── 100_views.sql
│   └── 200_personas.sql     (nemotron-personas 로 migrate 예정)
│
├── docs/                    핸드오프 + 결정 + 가이드
│   └── incidents/
│       └── 2026-05-14-threads-self-delete.md   사고 리뷰
│
├── crontab                  ★ 10 자동화
├── docker-compose.yml       3 service (magnet-mcp + magnet-cron + magnet-postgres)
├── CLAUDE.md                ★ 자동 로드 컨텍스트
└── SECURITY.md
```

## 데이터 모델 (multi-tenant + RLS)

| 테이블 | 역할 |
|---|---|
| `campaign_metrics_daily` | 광고 성과 (spend, impr, conv, roas, cac) |
| `posts_fb` / `posts_ig` / `posts_threads` | 소셜 게시물 + insights |
| `threads_post_snapshots` | Threads snapshot (사고 회복 자산) |
| `decisions` | 의사결정 audit ledger |
| `campaign_budgets_proposed` | 예산 변경 안 (HMAC 서명, TTL) |
| `copy_ab_tests` | A/B 테스트 메타 |

**RLS**: `POSTGRES_USER=magnet_app` (non-superuser, BYPASSRLS=false), connection stage 에서 `SET app.tenant_id = &lt;id&gt;` 주입. 기본값 `tenant_id=1` (realchoice).

## Crontab (10 task)

| 주기 | 작업 |
|---|---|
| 매시 정각 | `sync_meta_live.py --since 0` + `refresh_daily_kpi.py` |
| 매시 정각 | `sync_threads_live.py` |
| 매시 10분 | `learning_reset_monitor.py` — 광고 학습 reset 회복 |
| 30분 주기 | `ingest_decisions.py realchoice` |
| 매일 01:00 | `naver_searchad_sync.py` |
| 매일 03:00 | `sync_meta_live.py --since 2` — 어제/그제 재폴링 |
| 매일 09:00 | `ad_xray_daily.py` — DataLab + keyword sync |
| 15분 주기 | `scheduled_carousel_publish.py` — 운영자 시간 트리거 |
| 매시 30분 | `sync_threads_full.py` — 게시물 snapshot |
| 20분 주기 | `scheduled_reply_publish.py` — 답글 자동 게시 |
| 매일 00:05 | `logrotate.sh` |
| 09:00 외 | `daily_monitor.py`, `faq_responder.py`, `roas_safety_net.py`, `refund_spike_safeguard.py`, `negative_feedback_alert.py`, `threads_token_refresh.py` |

## Decisions Ledger (hash-chained)

`data/decisions/realchoice.jsonl` 에 모든 의사결정 append-only 저장:

```json
{
  "ts": "2026-05-20T15:00:00+09:00",
  "actor": "magnet-agent",
  "type": "ad_paused",
  "context": {"campaign_id": "23845...", "reason": "ROAS < 1.5"},
  "prev_hash": "sha256:abc..."   // 이전 line 의 SHA-256
}
```

`decisions_verify_chain_integrity()` 도구로 체인 무결성 검증.

## Brand SoT (operator + voice)

모든 카피·답글은 다음 2단계 강제:

1. `marketing_compose_post_prompt({...})` — brand SoT 자동 주입
2. `marketing_validate_copy(text)` — 광고법·금기 키워드·해시태그 정책 검증

`brand/personas/operator.yaml`:
```yaml
identity: "F&B 10년차 두 친구, 매장 비하인드"
disclose: false
hide:
  - real_name
  - exact_store_name
```

`brand/voice/threads.yaml`:
```yaml
tone: "친구 톤, 농담 OK"
no_hashtag: true            # Threads 는 해시태그 X
forbidden:
  - "최고", "1위", "유일"    # 광고법
length_max: 500
```

## Threads 사고 (2026-05-14, 영구 기록)

Threads 브라우저 자동화의 selector 결함으로 본 게시물 3개 + 모든 반응 영구 손실.

| 항목 | 값 |
|---|---|
| Root cause | `threads_browser.py` selector 가 답글 menu 에서 parent thread menu 잡음 |
| Impact | 운영자 carousel 본 게시물 3개 + 모든 반응 |
| Recovery | `data/pending-monitor-actions/2026-05-15-carousel-relaunch.md` (Graph API 재게시) |
| Guard | `MAGNET_THREADS_DANGER_ENABLED=1` 환경변수 없으면 delete/unrepost raise |

이후 모든 destructive Threads op (delete, unrepost) **manual only**, snapshot 매시간 백업으로 회복 자산 확보.

## 자가발전 에이전트

- 매시간 in-session loop + 매일 04:00 KST deep round
- 산물: `data/reports/YYYY-MM-DD.md`, `config/thresholds.yaml` (자율 조정)
- Token cap: 100K/round
- `[NEEDS_REVIEW]` 섹션으로 본 세션·사용자 결정 위임

## 외부 의존성

| 시스템 | 통합 |
|---|---|
| Meta Ads + CAPI | Marketing API v19+ |
| Naver SearchAd | API (HMAC-SHA256) |
| Threads / IG / FB | Graph API + 브라우저 자동화 (호스트 macOS Playwright) |
| stream MCP | 신호 수신 (purchase, inventory_alert) |
| nemotron-personas | 페르소나 조회 (별도 MCP) |
| truvia-marketing-agents | 콘텐츠 발주 인터페이스 |

## Tenant ID Mapping (D-magnet-tenant-map-1)

magnet 의 `MAGNET_TENANT_ID` 는 **service-internal RLS tenant_id** 이며, AXE Labs platform 의 customer ID (`customers.yaml` key) 와 **별개 namespace** 입니다. 본 섹션은 두 ID 의 mapping rule 을 명시합니다.

### 두 ID 의 분리

| 식별자 | 출처 | 예시 | 변경 책임 |
|---|---|---|---|
| **AXE customer ID** | `customers.yaml` 의 top-level key | `axe`, `realchoice` | 운영자 (액스코퍼레이션) |
| **`MAGNET_TENANT_SLUG`** | magnet `.env.local` 의 customer-name string | `realchoice` | service operator |
| **`MAGNET_TENANT_ID`** | magnet DB 의 RLS row identifier (`magnet.tenant.id`, integer) | `1` (realchoice) | DB migration |

같은 customer 가 두 ID 모두 가질 수 있으나 **string ↔ integer 매핑** 이 별도 lookup 필요.

### SSOT — `customers.yaml` 의 `service_tenant_map` (D-magnet-tenant-map-1, 2026-05-23)

신설 필드. 각 customer 블록 안에서 customer 가 사용하는 각 service 의 internal tenant id 를 명시:

```yaml
customers:
  realchoice:
    ...
    service_tenant_map:        # NEW field (D-magnet-tenant-map-1)
      magnet:
        tenant_slug: "realchoice"
        tenant_id: 1
      stream:
        tenant_id: 1           # truvia_ssot (port 5433) 의 tenant row
  axe:
    ...
    service_tenant_map:
      # axe customer 가 magnet 사용 시 tenant_id=2 부여 (충돌 회피)
      magnet:
        tenant_slug: "axe"
        tenant_id: 2
```

### 신규 customer 가 magnet 사용 시 — 충돌 회피 절차

1. 운영자가 `customers.yaml` 의 신규 customer 블록에 `service_tenant_map.magnet` 등재 — `tenant_id` 는 **현재 최대값 + 1**
2. magnet DB migration 또는 admin tool 로 `magnet.tenant` 에 row INSERT (id=신규)
3. customer macmini 의 magnet `.env.local` 에 `MAGNET_TENANT_SLUG`/`MAGNET_TENANT_ID` 등재 (axe ship 이 customers.yaml manifest 에서 자동 주입 — `B-magnet-tenant-env-injection` 향후 작업)
4. RLS 검증: `SET app.tenant_id=<신규>` 후 cross-tenant read 가 빈 결과 보장 (테스트)

### realchoice 측 코드 자산 호환성

`~/magnet` 의 코드 자산은 변경 없음 — RLS 구조 + `tenant_id=1` (realchoice) 유지. 본 신설 mapping 은 **운영자 측 manifest 가시성** 만 추가 (DB schema 영향 0).

### 향후 axe 가 magnet 사용 시

axe customer 가 magnet 도입을 결정하면:
- `customers.yaml > customers.axe.service_tenant_map.magnet.tenant_id = 2` 등재
- magnet DB 에 `INSERT INTO magnet.tenant (id, slug) VALUES (2, 'axe')`
- 양 customer 의 magnet 데이터는 RLS 로 격리, 충돌 0

## 환경 변수 (140+ 라인 `.env.example`)

```bash
# DB
POSTGRES_DB=magnet
POSTGRES_USER=magnet_app
POSTGRES_PASSWORD=
POSTGRES_HOST=

# Multi-tenant — service-internal namespace (Tenant ID Mapping 섹션 참조)
MAGNET_TENANT_SLUG=realchoice
MAGNET_TENANT_ID=1

# Meta Ads
META_ADS_ACCESS_TOKEN=
META_AD_ACCOUNT_ID=
MAGNET_PROPOSAL_HMAC_KEY=
META_PIXEL_ID=

# Naver
NAVER_AD_CUSTOMER_ID=
NAVER_AD_API_KEY=
NAVER_AD_SECRET_KEY=
MAGNET_NAVER_AD_LIVE=1

# Transport
MAGNET_TRANSPORT=sse
MAGNET_HTTP_HOST=127.0.0.1
MAGNET_HTTP_PORT=8770

# Decisions
MAGNET_DECISIONS_LOG_PATH=/data/decisions/realchoice.jsonl

# Threads guard
MAGNET_THREADS_DANGER_ENABLED=     # 미설정이 안전 (delete/unrepost raise)
```

## 관련 문서

- [Stream service](/services/stream) — sales/SCM 신호 발신자
- [/Users/axe/magnet/docs/](https://github.com/soohunkang/magnet/tree/main/docs) — 핸드오프 + incidents
- [/Users/axe/magnet/CLAUDE.md](https://github.com/soohunkang/magnet/blob/main/CLAUDE.md) — 자동 로드 컨텍스트
