Blog

Managed Agents から X に自動投稿する

2026-08-30 16:04:09

Managed Agents から X に自動投稿する

前回は Google Workspace MCP を扱いました。今回はXです。エージェントに投稿させます。

Xの開発者向けガイドラインは「APIの資格情報やトークンを決して共有しないこと」と定めています。違反はアプリの停止やアカウント凍結につながりえます。エージェントに操作させる場合も同じで、トークンをエージェントが読める場所に置かない構成が要ります。

この記事では、エージェントに認証情報を見せずにXを操作する方法を扱います。

認証の前提、トークンの隠し方、更新の回し方、料金の落とし穴、守るべき規約の順に見ていき、最後に実際に1件投稿します。

Xの認証で押さえておく点

POST https://api.x.com/2/tweets に使えるのは OAuth 2.0 のユーザーコンテキストで、スコープは tweet.writetweet.readusers.read です(参考: Create Posts - docs.x.com)。

X APIでは、PKCEが選択ではなく必須となり、サポートする grant type は「PKCE付きの認可コード」と「リフレッシュトークン」の2つだけなので、code_challenge を付けない実装は通りません。

X公式MCPでは投稿できない

Xは公式のMCPサーバを出しています。https://api.x.com/mcp です。あるなら繋ぐだけで済みそうですが、公開されている機能一覧を読むと、Posts(取得・リポスト者一覧・カウント)、Search、Users、Bookmarks、News & Trends、Articles(下書き作成と公開)まではありますが、通常の投稿作成が入っていません。

読み取り用途なら有力な選択肢ですが、投稿には使えません。

トークンをエージェントに見せずに渡す

前回書いた Vault は、認証情報をAnthropic側に預けてトークン管理を移譲する仕組みでした。今回使うのは mcp_oauth ではなく environment_variable です。

mcp_oauthstatic_bearermcp_server_url に紐づいていて、そのMCPサーバへ接続するときだけ注入されます。environment_variablesecret_name に紐づき、リクエスト(POST /2/tweets)が外へ出る直前に割り込んで、サンドボックスに置かれたプレースホルダを本物のトークンに差し替えます。

経路

使える資格情報

任せられること

X公式MCP に繋ぐ

mcp_oauth

トークンの更新(プラットフォームが代行)

POST /2/tweets を直接叩く

environment_variable

値の秘匿(egressで差し替え。更新は自前)

安全のため、通信先を api.x.com に限り、差し替える場所をヘッダだけに絞って登録しておくと、エージェントは Authorization: Bearer $X_ACCESS_TOKEN と書くだけで済みます。

残る問題は更新です。トークンの自動更新は mcp_oauth にしか無く、それはMCPサーバURLに紐づいています。environment_variable にその仕組みはないので、更新は自分で回すことになります(参考: OAuth 2.0 Authorization Code Flow with PKCE - docs.x.com)。

要点: Vault の environment_variable なら、トークンを隠したまま投稿できる。ただし更新は自前で回す。

投稿の単価はURLの有無で13倍変わる

Xの API は従量課金です。投稿の単価が、内容によって変わります(参考: 公式の料金ページ)。

操作

単価

投稿の作成

$0.015 / リクエスト

URLを含む投稿の作成

$0.200 / リクエスト

投稿の作成(summoned)

$0.010 / リクエスト

summoned は、相手から呼ばれて返す場合の単価です。プログラムからの返信はこの形でしか認められていません。

残る2行の差は13倍です。1日3回投稿する設計だと、URL付きで月およそ18ドル、平文なら月1.35ドル。絶対額としては小さく、成果が見えているなら払えばよい水準ですが、規模が大きくなると費用対効果を検証する必要がありそうです。

レート制限も見ておくと、POST /2/tweets は1ユーザーあたり15分で100件、アプリあたり24時間で10,000件です。

守らなければならないルール

自動投稿には公式のルールがあります(参考: X Automation Rules)。前提として、自動投稿そのものは許可されており、「情報提供・娯楽・目新しさを目的とした自動投稿を行ってよい」と明記があります。そのうえで、いくつか外せない規約があります。

  • APIを使わない自動化は禁止
    • 「ブラウザを自動操作すれば無料では」と考えたくなるところですが、ウェブサイトをスクリプトで操作するようなAPI以外の自動化。これらの手法の使用はアカウントの永久凍結につながる可能性があります。
  • 重複投稿は禁止
    • 1つのアカウント、または運用する複数アカウントにまたがって、重複または実質的に同一の投稿をしてはならない。
  • AIによるリプライbotは事前承認が必要
    • AIリプライbotの展開または運用には、Xからの事前の書面による明示的な承認が必要です。キーワード検索だけに基づく自動リプライも認められていません。そもそも、返信はAPI側でも塞がれています。プログラムからの返信は、元の投稿の作者がこちらを@メンションするか、こちらの投稿を引用した場合、つまり相手から呼ばれた場合にのみ認められます。また、APIからのフォロー・いいね・引用投稿もEnterprise限定になっています。つまり「自分から絡みにいく」botは、規約の前に仕様で成立しません。
  • 自動アカウントであることの表示は必須
    • これは自動化ルールのページには書かれておらず、Developer Guidelines にあります。「X APIを使うすべての自動アカウントは、次の要件を満たさなければならない」として、プロフィールの「Automated」ラベルを有効にすること、そして誰が運用しているかをbioに明記することの2つが挙げられています。

実検証: 実際に投稿する

構成は下記です。

  • Vault に environment_variable を登録し、差し込む場所はヘッダだけにする
  • エージェントに渡すツールは bash だけ
  • リフレッシュトークンはSecret Managerで管理
  • セッションを張る前にアクセストークンを更新し、Vault の値を差し替える
  • 依頼は「『ぼくはまちちゃん』で1件投稿してください」

トークンをリフレッシュすると新しいリフレッシュトークンが返り、古いほうは使えなくなるため、保存時は順番に気をつけてください。新しいリフレッシュトークンの保存を先に済ませ、そのあとでVaultのアクセストークンを更新します。

VaultのinjectionLocation をヘッダだけにしているのには理由があります。差し込む場所を絞らないと本文にも効くようになり、シークレットの漏洩リスクがあるからです。

ツールは bash だけにしています。curl を打てれば足りるので、それ以外は開けません。既定を無効にして bash だけを有効にします。本検証では実行のたびにトークンリフレッシュしていますが、実運用では期限を持っておいて、切れる手前でだけ呼べば足ります。

import static com.google.common.base.Preconditions.checkState;

import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse;
import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
import com.google.cloud.secretmanager.v1.SecretName;
import com.google.cloud.secretmanager.v1.SecretPayload;
import com.google.cloud.secretmanager.v1.SecretVersionName;
import com.google.common.base.Strings;
import com.google.protobuf.ByteString;
import java.io.IOException;

/**
 * X の refresh token を Google Secret Manager で管理する。
 *
 * <p>Secret 自体はあらかじめ作成しておく。 更新時には既存 Secret に新しい SecretVersion を追加する。
 */
public class XRefreshTokenStore {

  private final String projectId;
  private final String secretId;

  XRefreshTokenStore(String projectId, String secretId) {
    checkState(!Strings.isNullOrEmpty(projectId) && !projectId.isBlank(), "GCP_PROJECT_ID を設定してください");
    checkState(!Strings.isNullOrEmpty(secretId) && !secretId.isBlank(), "X_REFRESH_TOKEN_SECRET_ID を設定してください");
    this.projectId = projectId;
    this.secretId = secretId;
  }

  /** Secret Manager の latest version から 現在の refresh token を取得する。 */
  String load() {
    SecretVersionName versionName = SecretVersionName.of(projectId, secretId, "latest");
    try (SecretManagerServiceClient client = SecretManagerServiceClient.create()) {
      AccessSecretVersionResponse response = client.accessSecretVersion(versionName);
      String refreshToken = response.getPayload().getData().toStringUtf8();
      checkState(!refreshToken.isBlank(), "Secret Manager の refresh token が空です");
      return refreshToken;
    } catch (IOException e) {
      throw new IllegalStateException("Secret Manager から refresh token を取得できませんでした", e);
    }
  }

  /** refresh token が変更された場合だけ 新しい SecretVersion として保存する。 */
  void saveIfChanged(String currentRefreshToken, String nextRefreshToken) {
    checkState(!Strings.isNullOrEmpty(nextRefreshToken) && !nextRefreshToken.isBlank(), "保存する refresh token が空です");

    /*
     * Xがrefresh_tokenを返さず、
     * 現在値をそのまま使う場合は
     * SecretVersionを増やさない。
     */
    if (nextRefreshToken.equals(currentRefreshToken)) {
      return;
    }

    SecretName secretName = SecretName.of(projectId, secretId);
    SecretPayload payload = SecretPayload.newBuilder().setData(ByteString.copyFromUtf8(nextRefreshToken)).build();

    try (SecretManagerServiceClient client = SecretManagerServiceClient.create()) {
      client.addSecretVersion(secretName, payload);
    } catch (IOException e) {
      throw new IllegalStateException("Secret Manager への refresh token 保存に失敗しました", e);
    }
  }
}
import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Strings;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;

/**
 * X OAuth 2.0 の refresh token を使って access token を更新する。
 *
 * <p>この処理はエージェントの外側で行う。 refresh token や client secret を Managed Agents のサンドボックスには渡さない。
 */
public class XTokenRefresher {

  private static final URI TOKEN_ENDPOINT = URI.create("https://api.x.com/2/oauth2/token");
  private static final ObjectMapper JSON = new ObjectMapper();

  /**
   * X token endpoint の更新結果。
   *
   * <p>refresh_token がレスポンスに含まれない場合は、呼び出し時に渡された refresh token をそのまま返す。
   */
  record Tokens(String accessToken, String refreshToken, long expiresInSeconds) {}

  private final String clientId;
  private final String clientSecret;
  private final HttpClient httpClient = HttpClient.newHttpClient();

  XTokenRefresher(String clientId, String clientSecret) {
    checkState(!Strings.isNullOrEmpty(clientId) && !clientId.isBlank(), "X_CLIENT_ID を設定してください");
    checkState(!Strings.isNullOrEmpty(clientSecret) && !clientSecret.isBlank(), "X_CLIENT_SECRET を設定してください");
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }

  /** refresh token から最新の access token を取得する。 */
  Tokens refresh(String refreshToken) {
    checkState(!Strings.isNullOrEmpty(refreshToken) && !refreshToken.isBlank(), "refresh token が空です");
    String form = "grant_type=refresh_token" + "&refresh_token=" + formEncode(refreshToken);
    String basic = Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes(UTF_8));
    HttpRequest request =
        HttpRequest.newBuilder(TOKEN_ENDPOINT)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .header("Accept", "application/json")
            .header("Authorization", "Basic " + basic)
            .POST(HttpRequest.BodyPublishers.ofString(form, UTF_8))
            .build();

    try {
      HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(UTF_8));
      if (response.statusCode() < 200 || response.statusCode() >= 300) {
        throw new IllegalStateException(
            "X OAuth token refresh failed: HTTP "
                + response.statusCode()
                + " / "
                + sanitizeError(response.body()));
      }
      JsonNode body = JSON.readTree(response.body());
      String accessToken = requiredText(body, "access_token");
      long expiresIn = requiredPositiveLong(body, "expires_in");

      /*
       * X が refresh_token を返した場合は、
       * その値を次回用として採用する。
       *
       * 公式文書では refresh token のローテーション挙動が
       * 明示されていないため、返らなかった場合は
       * 現在の refresh token を使い続ける。
       */
      String nextRefreshToken = optionalText(body, "refresh_token", refreshToken);
      return new Tokens(accessToken, nextRefreshToken, expiresIn);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new IllegalStateException("X OAuth token refresh が中断されました", e);
    } catch (IOException e) {
      throw new IllegalStateException("X OAuth token refresh に失敗しました", e);
    }
  }

  private static String formEncode(String value) {
    return URLEncoder.encode(value, UTF_8);
  }

  private static String requiredText(JsonNode body, String name) {
    JsonNode value = body.get(name);
    checkState(value != null && value.isString() && !value.asString().isBlank(),
        "X OAuth response に %s がありません",
        name);
    return value.asString();
  }

  private static long requiredPositiveLong(JsonNode body, String name) {
    JsonNode value = body.get(name);
    checkState(value != null && value.isNumber(), "X OAuth response に %s がありません", name);
    long result = value.asLong();
    checkState(result > 0, "X OAuth response の %s が不正です: %s", name, result);
    return result;
  }

  private static String optionalText(JsonNode body, String name, String defaultValue) {
    JsonNode value = body.get(name);
    if (value == null || !value.isString() || value.asString().isBlank()) {
      return defaultValue;
    }
    return value.asString();
  }

  /** エラー本文全体をログに出さず、 error / error_description だけを取り出す。 */
  private static String sanitizeError(String responseBody) {
    try {
      JsonNode body = JSON.readTree(responseBody);
      String error = optionalText(body, "error", "unknown_error");
      String description = optionalText(body, "error_description", "");
      return description.isBlank() ? error : error + ": " + description;
    } catch (RuntimeException e) {
      return "response body could not be parsed";
    }
  }
}
import static com.google.common.base.Preconditions.checkState;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.JsonValue;
import com.anthropic.core.http.StreamResponse;
import com.anthropic.models.beta.agents.AgentCreateParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsAgentToolset20260401Params;
import com.anthropic.models.beta.agents.BetaManagedAgentsAgentToolsetDefaultConfigParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsAlwaysAllowPolicy;
import com.anthropic.models.beta.agents.BetaManagedAgentsBashToolConfigParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsModel;
import com.anthropic.models.beta.agents.BetaManagedAgentsModelConfigParams;
import com.anthropic.models.beta.environments.EnvironmentCreateParams;
import com.anthropic.models.beta.sessions.SessionCreateParams;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsStreamSessionEvents;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsTextBlock;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsUserMessageEventParams;
import com.anthropic.models.beta.sessions.events.EventSendParams;
import com.anthropic.models.beta.vaults.VaultCreateParams;
import com.anthropic.models.beta.vaults.credentials.BetaManagedAgentsEnvironmentVariableCreateParams;
import com.anthropic.models.beta.vaults.credentials.BetaManagedAgentsEnvironmentVariableUpdateParams;
import com.anthropic.models.beta.vaults.credentials.BetaManagedAgentsInjectionLocationParams;
import com.anthropic.models.beta.vaults.credentials.CredentialCreateParams;
import com.anthropic.models.beta.vaults.credentials.CredentialUpdateParams;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import java.util.Iterator;
import tools.jackson.databind.ObjectMapper;

/**
 * Managed Agents から X に投稿するデモ。
 *
 * <p>投稿用 access token は Anthropic Vault の environment_variable に保存する。 サンドボックスにはプレースホルダだけが置かれ、 実際の値は egress で差し替えられる。
 * <p>refresh token は Google Secret Manager に保存し、 エージェントの外側で X の token endpoint を呼び出して更新する。
 * <p>更新時には、新しい refresh token が返った場合は 先に Secret Manager に保存してから access token を Vault に書き戻す。
 *
 * <p>セットアップ:
 * <pre>
 * mvn -q compile exec:java -Dexec.args="setup"
 * </pre>
 *
 * <p>実行:
 * <pre>
 * mvn -q compile exec:java
 * </pre>
 */
public final class XPostingAgent {

  private static final ObjectMapper JSON = new ObjectMapper();
  private static final String X_API_HOST = "api.x.com";
  private static final String SECRET_NAME = "X_ACCESS_TOKEN";
  private static final String SYSTEM =
      """
      渡されたJSONを body にして、
      bash から次のリクエストを1回だけ送ってください。

      JSONは改変しないでください。
      トークンは環境変数 %s にあります。

      curl -sS -X POST https://%s/2/tweets \\
        -H "Authorization: Bearer $%s" \\
        -H "Content-Type: application/json" \\
        -d '<渡されたJSON>'

      レスポンスをそのまま報告してください。
      トークンの中身は出力しないでください。
      """
        .formatted(SECRET_NAME, X_API_HOST, SECRET_NAME);

  private static final String TEXT = "ぼくはまちちゃん";
  private final AnthropicClient client;
  private final XTokenRefresher refresher;
  private final XRefreshTokenStore refreshTokenStore;

  XPostingAgent(AnthropicClient client, XTokenRefresher refresher, XRefreshTokenStore refreshTokenStore) {
    this.client = client;
    this.refresher = refresher;
    this.refreshTokenStore = refreshTokenStore;
  }

  public static void main(String[] args) {
    String apiKey = System.getenv().getOrDefault("ANTHROPIC_API_KEY", "");
    if (apiKey.isBlank()) {
      System.out.println("ANTHROPIC_API_KEY を設定してください。");
      return;
    }
    XPostingAgent app =
        new XPostingAgent(
            AnthropicOkHttpClient.fromEnv(),
            new XTokenRefresher(requiredEnvironment("X_CLIENT_ID"), requiredEnvironment("X_CLIENT_SECRET")),
            new XRefreshTokenStore(
                requiredEnvironment("GCP_PROJECT_ID"),
                requiredEnvironment("X_REFRESH_TOKEN_SECRET_ID")));
    if (args.length > 0 && "setup".equalsIgnoreCase(args[0])) {
      app.setup();
      return;
    }

    app.run(
        requiredEnvironment("ANTHROPIC_ENVIRONMENT_ID"),
        requiredEnvironment("ANTHROPIC_AGENT_ID"),
        requiredEnvironment("ANTHROPIC_VAULT_ID"),
        requiredEnvironment("ANTHROPIC_CREDENTIAL_ID"));
  }

  /** 投稿するJSONはモデルではなくJava側で組み立てる。 */
  private static String body() {
    return JSON.createObjectNode().put("text", TEXT).toString();
  }

  /**
   * Secret Manager の最新 refresh token から access token を取得する。
   *
   * <p>新しい refresh token が返された場合は、 Vault を更新する前に Secret Manager へ保存する。
   */
  private XTokenRefresher.Tokens mintAccessToken() {
    String currentRefreshToken = refreshTokenStore.load();
    XTokenRefresher.Tokens tokens = refresher.refresh(currentRefreshToken);

    /*
     * refresh token に変更があれば、
     * 先に永続化する。
     */
    refreshTokenStore.saveIfChanged(currentRefreshToken, tokens.refreshToken());
    return tokens;
  }

  /**
   * Environment / Agent / Vault / Credential を作成する。
   *
   * <p>これらは1回だけ作り、 IDを保存して以後使い回す。
   */
  void setup() {
    var environment =
        client
            .beta()
            .environments()
            .create(EnvironmentCreateParams.builder().name("x-posting-environment").build());

    /*
     * bashだけを有効にする。
     */
    var agent =
        client
            .beta()
            .agents()
            .create(
                AgentCreateParams.builder()
                    .name("x-poster")
                    .model(
                        BetaManagedAgentsModelConfigParams.builder()
                            .id(BetaManagedAgentsModel.CLAUDE_SONNET_5)
                            .build())
                    .system(SYSTEM)
                    .addTool(
                        BetaManagedAgentsAgentToolset20260401Params.builder()
                            .type(BetaManagedAgentsAgentToolset20260401Params.Type.AGENT_TOOLSET_20260401)
                            .defaultConfig(
                                BetaManagedAgentsAgentToolsetDefaultConfigParams.builder()
                                    .enabled(false)
                                    .build())
                            .addConfig(
                                BetaManagedAgentsBashToolConfigParams.builder()
                                    .enabled(true)
                                    .permissionPolicy(
                                        BetaManagedAgentsAlwaysAllowPolicy.builder()
                                            .type(BetaManagedAgentsAlwaysAllowPolicy.Type.ALWAYS_ALLOW)
                                            .build())
                                    .build())
                            .build())
                    .build());

    var vault =
        client
            .beta()
            .vaults()
            .create(
                VaultCreateParams.builder()
                    .displayName("fundoshi_y")
                    .metadata(
                        VaultCreateParams.Metadata.builder()
                            .putAdditionalProperty("external_user_id", JsonValue.from("usr_x_bot"))
                            .build())
                    .build());

    /*
     * Secret Manager に保存されている refresh token から
     * 最初の access token を取得する。
     */
    XTokenRefresher.Tokens tokens = mintAccessToken();

    /*
     * access token は environment_variable credential として
     * Vault に保存する。
     *
     * secretName がサンドボックス側の環境変数名。
     *
     * 実値はサンドボックスには置かれず、
     * api.x.com 宛てのヘッダでだけ差し替えられる。
     */
    var credential =
        client
            .beta()
            .vaults()
            .credentials()
            .create(
                CredentialCreateParams.builder()
                    .vaultId(vault.id())
                    .displayName("X access token")
                    .auth(
                        BetaManagedAgentsEnvironmentVariableCreateParams.builder()
                            .type(BetaManagedAgentsEnvironmentVariableCreateParams.Type.ENVIRONMENT_VARIABLE)
                            .secretName(SECRET_NAME)
                            .secretValue(tokens.accessToken())
                            .limitedNetworking(ImmutableList.of(X_API_HOST))
                            .injectionLocation(
                                BetaManagedAgentsInjectionLocationParams.builder()
                                    .header(true)
                                    .build())
                            .build())
                    .build());

    System.out.println("export ANTHROPIC_ENVIRONMENT_ID=\"" + environment.id() + "\"");
    System.out.println("export ANTHROPIC_AGENT_ID=\"" + agent.id() + "\"");
    System.out.println("export ANTHROPIC_VAULT_ID=\"" + vault.id() + "\"");
    System.out.println("export ANTHROPIC_CREDENTIAL_ID=\"" + credential.id() + "\"");
  }

  /** access token を更新してから Session を作成して X に投稿する。 */
  void run(String environmentId, String agentId, String vaultId, String credentialId) {

    /*
     * 1. Secret Managerからrefresh token取得
     * 2. X token endpointでrefresh
     * 3. 新しいrefresh tokenがあればSecret Managerに保存
     */
    XTokenRefresher.Tokens tokens = mintAccessToken();

    /*
     * 4. Vault の access token を更新
     *
     * secret_name は変更しない。
     * 値だけ差し替える。
     */
    client
        .beta()
        .vaults()
        .credentials()
        .update(
            CredentialUpdateParams.builder()
                .vaultId(vaultId)
                .credentialId(credentialId)
                .auth(
                    BetaManagedAgentsEnvironmentVariableUpdateParams.builder()
                        .type(BetaManagedAgentsEnvironmentVariableUpdateParams.Type.ENVIRONMENT_VARIABLE)
                        .secretValue(tokens.accessToken())
                        .build())
                .build());

    /*
     * 5. access token 更新後に Session を作る
     */
    var session =
        client
            .beta()
            .sessions()
            .create(
                SessionCreateParams.builder()
                    .environmentId(environmentId)
                    .agent(agentId)
                    .addVaultId(vaultId)
                    .title("X投稿")
                    .build());

    /*
     * 6. 投稿用JSONを送る
     */
    client
        .beta()
        .sessions()
        .events()
        .send(
            session.id(),
            EventSendParams.builder()
                .addUserMessageEvent(
                    ImmutableList.of(
                        BetaManagedAgentsUserMessageEventParams.Content.ofText(
                            BetaManagedAgentsTextBlock.builder()
                                .type(BetaManagedAgentsTextBlock.Type.TEXT)
                                .text("次のJSONで1件投稿してください: " + body())
                                .build())))
                .build());

    StringBuilder answer = new StringBuilder();

    /*
     * 7. Session完了までイベントを読む
     */
    try (StreamResponse<BetaManagedAgentsStreamSessionEvents> response =
        client.beta().sessions().events().streamStreaming(session.id())) {
      Iterator<BetaManagedAgentsStreamSessionEvents> events = response.stream().iterator();
      while (events.hasNext()) {
        BetaManagedAgentsStreamSessionEvents event = events.next();
        if (event.isAgentToolUse()) {

          /*
           * ここに表示されるのは
           * $X_ACCESS_TOKEN というプレースホルダ。
           *
           * 実際の token 値は表示されない。
           */
          var toolUse = event.asAgentToolUse();
          System.out.println("[" + toolUse.name() + "] " + toolUse.input());
        } else if (event.isAgentMessage()) {
          event.asAgentMessage().content().forEach(block -> answer.append(block.text()));
        } else if (event.isSessionError()) {
          throw new IllegalStateException("session error: " + event.asSessionError());
        } else if (event.isSessionStatusIdle()
            && event.asSessionStatusIdle().stopReason().isEndTurn()) {
          break;
        }
      }
    }
    System.out.println();
    System.out.println("A. " + answer.toString().strip());
  }

  private static String requiredEnvironment(String name) {
    String value = System.getenv(name);
    checkState(!Strings.isNullOrEmpty(value) && !value.isBlank(), "%s を設定してください", name);
    return value;
  }
}

実行結果

[bash] Input{additionalProperties={command=curl -sS -X POST https://api.x.com/2/tweets \
  -H "Authorization: Bearer $X_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"ぼくはまちちゃん"}'
}}

A. Optional[BetaManagedAgentsTextBlock{text=投稿が完了しました。レスポンスは以下の通りです。

```json
{"data":{"edit_history_tweet_ids":[""],"id":"2093653354710745088","text":"ぼくはまちちゃん"}}
```

- ツイートID: `2093653354710745088`
- 投稿内容: 「ぼくはまちちゃん」, type=text, additionalProperties={}}]

まとめ

本記事では、Managed AgentとOAuth認証連携を安全に実施する例を紹介しました。

Vault の environment_variable を使えば、エージェントにトークンを見せずにXへ投稿できます。

あとはXの規約を守ること。「非API経路の自動化は永久凍結」、「重複投稿は禁止」、「自動アカウントであることの表示は必須」です。

elatt では、こうした手作業を自動化し、お客さまの業務効率化を推進しています。設計や事業の相談などお気軽にお問い合わせからご連絡ください。

Managed Agents から X に自動投稿する - 合同会社ふんどし